From 874bbaaa679fe7655efe6b43e328b3524e4afd74 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Wed, 4 Feb 2026 12:47:35 -0800 Subject: [PATCH 1/3] message signing for UCP requests & responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context / Closes #135. Defines a layered signature architecture: SHARED FOUNDATION ├── Canonicalization: JCS (RFC 8785) ├── Algorithms: ES256 (required), ES384, ES512 ├── Key Format: JWK (RFC 7517) ├── Key Discovery: signing_keys[] in /.well-known/ucp └── Replay Protection: idempotency-key (business layer) │ ├── REST BINDING (RFC 9421) │ Headers: Signature, Signature-Input, UCP-Content-Digest-JCS │ └── MCP BINDING (RFC 7515 Appendix F) Fields: meta.signature, meta.idempotency-key, meta.ucp-agent JCS canonicalization (RFC 8785): - Deterministic JSON serialization before signing - Avoids whitespace/key-ordering verification failures - Custom header `UCP-Content-Digest-JCS` (not RFC 9530 Content-Digest) because we hash canonicalized JSON, not raw bytes Transport-specific formats: - REST uses RFC 9421 HTTP Message Signatures (modern standard) - MCP uses detached JWS (RFC 7515 Appendix F) in meta.signature - Both sign the full message; MCP excludes only meta.signature field Changes to OpenAPI: - `Request-Signature` header → `Signature` + `Signature-Input` headers - `X-Detached-JWT` response header → `Signature` + `Signature-Input` - `UCP-Content-Digest-JCS` header for body digest Other updates: - checkout-rest.md, checkout-mcp.md: Added Message Signing sections - order.md: Rewrote webhook signing to use RFC 9421 - ap2-mandates.md: Now references signatures.md for crypto primitives - openrpc.json: Added signature field to meta, added meta to cart methods - mkdocs.yml: Added signatures.md to nav and llmstxt sections --- .cspell.json | 6 +- .cspell/custom-words.txt | 1 + docs/specification/ap2-mandates.md | 32 +- docs/specification/cart.md | 8 +- docs/specification/checkout-mcp.md | 79 +++ docs/specification/checkout-rest.md | 59 ++ docs/specification/order.md | 85 ++- docs/specification/overview.md | 12 + docs/specification/signatures.md | 977 ++++++++++++++++++++++++++ mkdocs.yml | 7 +- source/services/shopping/openapi.json | 175 ++++- source/services/shopping/openrpc.json | 19 + 12 files changed, 1384 insertions(+), 76 deletions(-) create mode 100644 docs/specification/signatures.md diff --git a/.cspell.json b/.cspell.json index 27f65385..3825edcf 100644 --- a/.cspell.json +++ b/.cspell.json @@ -18,7 +18,11 @@ "ignoreRegExpList": [ "src\\s*=\\s*(\"[^\"]*\"|'[^']*')", "(\"token\"|token)\\s*:\\s*(\"[^\"]*\"|'[^']*')", - "(\"auth_jwt\"|auth_jwt)\\s*:\\s*(\"[^\"]*\"|'[^']*')" + "(\"auth_jwt\"|auth_jwt)\\s*:\\s*(\"[^\"]*\"|'[^']*')", + "(\"x\"|\"y\")\\s*:\\s*\"[A-Za-z0-9_-]+\"", + "sig1=:[A-Za-z0-9+/=_.]+:", + "eyJ[A-Za-z0-9_-]+\\.\\.?[A-Za-z0-9_-]*", + "M[A-Z][A-Za-z0-9]{3,}\\.\\.\\." ], "dictionaryDefinitions": [ { diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index bfae6fd2..b5b1d64a 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -85,3 +85,4 @@ upsells vulnz yaml yml +keyid diff --git a/docs/specification/ap2-mandates.md b/docs/specification/ap2-mandates.md index aad35f84..8a67ccfe 100644 --- a/docs/specification/ap2-mandates.md +++ b/docs/specification/ap2-mandates.md @@ -123,15 +123,16 @@ If a public key cannot be resolved, or if the signature is invalid, the business ## Cryptographic Requirements -### Signature Algorithm +This extension uses the cryptographic primitives defined in the +[Message Signatures](signatures.md) specification: -All signatures **MUST** use one of the following algorithms: +* **Algorithms:** ES256 (required), ES384, ES512 +* **Canonicalization:** JCS ([RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785)) +* **Key Format:** JWK ([RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517)) +* **Key Discovery:** `signing_keys[]` in `/.well-known/ucp` -| Algorithm | Description | -| :-------- | :---------------------------------------------------- | -| `ES256` | ECDSA using P-256 curve and SHA-256 (**RECOMMENDED**) | -| `ES384` | ECDSA using P-384 curve and SHA-384 | -| `ES512` | ECDSA using P-521 curve and SHA-512 | +See [Message Signatures - Shared Foundation](signatures.md#shared-foundation) +for complete details on algorithms, key format, and key rotation. ### Business Authorization @@ -213,17 +214,14 @@ selective disclosure, key binding) is defined by the ### Canonicalization -For signature computation over JSON payloads, implementations **MUST** use -**JSON Canonicalization Scheme (JCS)** as defined in -[RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785). +All JSON payloads **MUST** be canonicalized using **JSON Canonicalization +Scheme (JCS)** per [RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785). +See [Message Signatures - JSON Canonicalization](signatures.md#json-canonicalization-jcs) +for canonicalization rules. -JCS produces a deterministic, byte-for-byte identical representation of -JSON data, ensuring signatures can be verified regardless of whitespace, -key ordering, or Unicode normalization differences. - -**Canonicalization Rule:** When computing the business's signature, exclude -the `ap2` field entirely. This ensures future AP2 fields are automatically -handled. +**AP2-Specific Rule:** When computing the business's `merchant_authorization` +signature, exclude the `ap2` field entirely. This ensures future AP2 fields +are automatically handled. ## The Mandate Flow diff --git a/docs/specification/cart.md b/docs/specification/cart.md index 29756608..6c00a575 100644 --- a/docs/specification/cart.md +++ b/docs/specification/cart.md @@ -86,7 +86,7 @@ SHOULD be linked for the duration of the checkout. * **After checkout completion** — Business MAY clear the cart based on TTL, completion of the checkout, or other business logic. Subsequent operations - on a cleared cart ID return `NOT_FOUND`; the platform can start a new + on a cleared cart ID return `not_found`; the platform can start a new session with `create_cart`. ## Guidelines @@ -96,7 +96,7 @@ SHOULD be linked for the duration of the checkout. * **MAY** use carts for pre-purchase exploration and session persistence. * **SHOULD** convert cart to checkout when user expresses purchase intent. * **MAY** display `continue_url` for handoff to business UI. -* **SHOULD** handle `NOT_FOUND` gracefully when cart expires or is canceled. +* **SHOULD** handle `not_found` gracefully when cart expires or is canceled. ### Business @@ -134,7 +134,7 @@ information for localized pricing estimates. ### Get Cart -Retrieves the latest state of a cart session. Returns `NOT_FOUND` if the cart +Retrieves the latest state of a cart session. Returns `not_found` if the cart does not exist, has expired, or was canceled. * [REST Binding](cart-rest.md#get-cart) @@ -152,7 +152,7 @@ state on the business side. ### Cancel Cart Cancels a cart session. Business MUST return the cart state before deletion. -Subsequent operations for this cart ID SHOULD return `NOT_FOUND`. +Subsequent operations for this cart ID SHOULD return `not_found`. * [REST Binding](cart-rest.md#cancel-cart) * [MCP Binding](cart-mcp.md#cancel_cart) diff --git a/docs/specification/checkout-mcp.md b/docs/specification/checkout-mcp.md index 68792956..a03156cd 100644 --- a/docs/specification/checkout-mcp.md +++ b/docs/specification/checkout-mcp.md @@ -650,6 +650,83 @@ as JSON-RPC `result` with `structuredContent` containing the UCP envelope and } ``` +## Message Signing + +All checkout operations **MUST** include message signatures per the +[Message Signatures](signatures.md) specification. + +### Request Signing + +Platforms **MUST** sign all requests using JWS Detached Content (RFC 7515 +Appendix F). The signature is placed in `meta.signature`: + +| Field | Required | Description | +| :--------------------- | :------- | :--------------------------------- | +| `meta.signature` | Yes | Detached JWS (`header..signature`) | +| `meta.ucp-agent` | Yes | Signer identity | +| `meta.idempotency-key` | Cond.* | Unique key for replay protection | + +\* Required for `complete_checkout` and `cancel_checkout` + +**Example Signed Request:** + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "complete_checkout", + "arguments": { + "meta": { + "ucp-agent": {"profile": "https://platform.example/.well-known/ucp"}, + "signature": "eyJhbGciOiJFUzI1NiIsImtpZCI6InBsYXRmb3JtLTIwMjUifQ..MEUCIQD...", + "idempotency-key": "550e8400-e29b-41d4-a716-446655440000" + }, + "id": "checkout_abc123", + "checkout": {"payment": {...}} + } + } +} +``` + +The signed payload is the entire JSON-RPC message with only `meta.signature` +removed, then JCS-canonicalized. + +See [Message Signatures - MCP Request Signing](signatures.md#mcp-request-signing) +for the complete signing algorithm. + +### Response Signing + +Response signatures are **REQUIRED** for: + +* `complete_checkout` responses (order confirmation) + +Response signatures are **OPTIONAL** for: + +* `create_checkout`, `get_checkout`, `update_checkout`, `cancel_checkout` + +**Example Signed Response:** + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [{"type": "text", "text": "..."}], + "structuredContent": { + "meta": { + "signature": "eyJhbGciOiJFUzI1NiIsImtpZCI6Im1lcmNoYW50LTIwMjUifQ..MFQCIH..." + }, + "checkout": {"id": "checkout_abc123", "status": "completed", ...} + } + } +} +``` + +See [Message Signatures - MCP Response Signing](signatures.md#mcp-response-signing) +for the complete signing algorithm. + ## Conformance A conforming MCP transport implementation **MUST**: @@ -661,6 +738,8 @@ A conforming MCP transport implementation **MUST**: `messages` array. 5. Validate tool inputs against UCP schemas. 6. Support HTTP transport with streaming. +7. Sign all requests per [Message Signatures](signatures.md) specification. +8. Verify signatures on incoming requests before processing. ## Implementation diff --git a/docs/specification/checkout-rest.md b/docs/specification/checkout-rest.md index 56d59348..93b6d6ae 100644 --- a/docs/specification/checkout-rest.md +++ b/docs/specification/checkout-rest.md @@ -1282,6 +1282,65 @@ with HTTP 200 and the UCP envelope containing `messages`: } ``` +## Message Signing + +All checkout operations **MUST** include message signatures per the +[Message Signatures](signatures.md) specification. + +### Request Signing + +Platforms **MUST** sign all requests using RFC 9421 HTTP Message Signatures: + +| Header | Required | Description | +| :----------------------- | :------- | :--------------------------------------- | +| `Signature-Input` | Yes | Describes signed components | +| `Signature` | Yes | Contains the signature value | +| `UCP-Content-Digest-JCS` | Cond.* | JCS-canonicalized body digest | + +\* Required for requests with a body (POST, PUT) + +**Example Signed Request:** + +```http +POST /checkout-sessions HTTP/1.1 +Host: merchant.example.com +Content-Type: application/json +UCP-Agent: profile="https://platform.example/.well-known/ucp" +UCP-Content-Digest-JCS: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: +Signature-Input: sig1=("@method" "@path" "ucp-content-digest-jcs" "content-type");created=1738617600;keyid="platform-2025" +Signature: sig1=:MEUCIQDTxNq8h7LGHpvVZQp1iHkFp9+3N8Mxk2zH1wK4YuVN8w...: + +{"line_items":[{"item":{"id":"item_123"},"quantity":2}]} +``` + +See [Message Signatures - REST Request Signing](signatures.md#rest-request-signing) +for the complete signing algorithm. + +### Response Signing + +Response signatures are **REQUIRED** for: + +* `complete_checkout` responses (order confirmation) + +Response signatures are **OPTIONAL** for: + +* `create_checkout`, `get_checkout`, `update_checkout`, `cancel_checkout` + +**Example Signed Response:** + +```http +HTTP/1.1 200 OK +Content-Type: application/json +UCP-Content-Digest-JCS: sha-256=:Y5fK8nLmPqRsT3vWxYzAbCdEfGhIjKlMnO...: +Signature-Input: sig1=("@status" "ucp-content-digest-jcs" "content-type");created=1738617601;keyid="merchant-2025" +Signature: sig1=:MFQCIH7kL9nM2oP5qR8sT1uV4wX6yZaB3cD...: + +{"id":"chk_123","status":"completed","order":{"id":"ord_456"}} +``` + +See [Message Signatures - REST Response Signing](signatures.md#rest-response-signing) +for the complete signing algorithm. + ## Security Considerations ### Authentication diff --git a/docs/specification/order.md b/docs/specification/order.md index 2188eefb..84a2dc9e 100644 --- a/docs/specification/order.md +++ b/docs/specification/order.md @@ -293,34 +293,73 @@ platform's profile and uses it to send order lifecycle events. ### Webhook Signature Verification Webhook payloads **MUST** be signed by the business and verified by the platform -to ensure authenticity and integrity. +to ensure authenticity and integrity. Signatures follow the +[Message Signatures](signatures.md) specification using the REST binding +(RFC 9421). + +**Required Headers:** + +| Header | Description | +| :----------------------- | :----------------------------------------- | +| `UCP-Agent` | Business profile URL (RFC 8941 Dictionary) | +| `Signature-Input` | Describes signed components | +| `Signature` | Contains the signature value | +| `UCP-Content-Digest-JCS` | JCS-canonicalized body digest | + +**Example Webhook Request:** + +```http +POST /webhooks/ucp/orders HTTP/1.1 +Host: platform.example.com +Content-Type: application/json +UCP-Agent: profile="https://merchant.example/.well-known/ucp" +UCP-Content-Digest-JCS: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: +Signature-Input: sig1=("@method" "@path" "ucp-content-digest-jcs" "content-type");keyid="merchant-2026" +Signature: sig1=:MEUCIQDTxNq8h7LGHpvVZQp1iHkFp9+3N8Mxk2zH1wK4YuVN8w...: + +{"id":"order_abc123","event_id":"evt_123","created_time":"2026-01-15T12:00:00Z",...} +``` #### Signing (Business) -1. Select a key from the `signing_keys` array in UCP profile. -2. Create a detached JWT (RFC 7797) over the request body using the selected key. -3. Include the JWT in the `Request-Signature` header. -4. Include the key ID in the JWT header's `kid` claim to allow the receiver to - identify which key to use for verification. +1. JCS-canonicalize the webhook payload ([RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785)) +2. Compute SHA-256 digest and set `UCP-Content-Digest-JCS` header +3. Build signature base per [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) +4. Sign using a key from `signing_keys` in the business's UCP profile +5. Set `Signature-Input` and `Signature` headers + +See [Message Signatures - REST Request Signing](signatures.md#rest-request-signing) +for complete algorithm. #### Verification (Platform) -1. Extract the `Request-Signature` header from the incoming webhook request. -2. Parse the JWT header to retrieve the `kid` (key ID). -3. Fetch the business's UCP profile from `/.well-known/ucp` (cache as appropriate). -4. Locate the key in `signing_keys` with the matching `kid`. -5. Verify the JWT signature against the request body using the public key. -6. If verification fails, reject the webhook with an appropriate error response. +**Authentication** (signature verification): -#### Key Rotation +1. Parse `Signature-Input` to extract `keyid` and signed components +2. Fetch business's UCP profile from `/.well-known/ucp` (cache as appropriate) +3. Locate key in `signing_keys` with matching `kid` +4. Verify `UCP-Content-Digest-JCS` matches JCS-canonicalized body +5. Reconstruct signature base and verify signature + +See [Message Signatures - REST Request Verification](signatures.md#rest-request-verification) +for complete algorithm. + +**Authorization** (order ownership): -The `signing_keys` array supports multiple keys to enable zero-downtime -rotation: +After verifying the signature, the platform **MUST** confirm the signer is +authorized to send events for the referenced order: + +1. Extract the order ID from the webhook payload +2. Verify the order was created with this business (profile URL matches) +3. Reject webhooks where the signer's profile doesn't match the order's business + +This prevents a malicious business from sending fake events for another +business's orders, even with a valid signature. + +#### Key Rotation -* **Adding a new key:** Add the new key to `signing_keys`, then start signing - with it. Verifiers will find it by `kid`. -* **Removing an old key:** After sufficient time for all in-flight webhooks to - be delivered, remove the old key from `signing_keys`. +See [Message Signatures - Key Rotation](signatures.md#key-rotation) for +zero-downtime key rotation procedures. ## Guidelines @@ -331,13 +370,13 @@ rotation: **Business:** -* **MUST** sign all webhook payloads using a key from their `signing_keys` - array (published in `/.well-known/ucp`). The signature **MUST** be included - in the `Request-Signature` header as a detached JWT (RFC 7797). +* **MUST** include `UCP-Agent` header with profile URL for signer identification +* **MUST** sign all webhook payloads per the + [Message Signatures](signatures.md) specification using RFC 9421 headers + (`Signature`, `Signature-Input`, `UCP-Content-Digest-JCS`). * **MUST** send "Order created" event with fully populated order entity * **MUST** send full order entity on updates (not incremental deltas) * **MUST** retry failed webhook deliveries -* **MUST** include business identifier in webhook path or headers ## Entities diff --git a/docs/specification/overview.md b/docs/specification/overview.md index 2681d6b0..41abf498 100644 --- a/docs/specification/overview.md +++ b/docs/specification/overview.md @@ -598,6 +598,18 @@ These failure types require different handling: | `capabilities_incompatible` | No compatible capabilities in intersection | 200 | result | | `version_unsupported` | Platform's UCP version is not supported | 200 | result | +**Signature Errors:** + +| Code | Description | REST | MCP | +| ---------------------- | ------------------------------------------------------ | ---- | ------ | +| `signature_missing` | Required signature header/field not present | 401 | -32000 | +| `signature_invalid` | Signature verification failed | 401 | -32000 | +| `key_not_found` | Key ID not found in signer's `signing_keys` | 401 | -32000 | +| `digest_mismatch` | Body digest doesn't match `UCP-Content-Digest-JCS` | 400 | -32600 | +| `algorithm_unsupported`| Signature algorithm not supported | 400 | -32600 | + +See [Message Signatures](signatures.md) for signature verification details. + **Protocol Errors:** | HTTP | Description | MCP | diff --git a/docs/specification/signatures.md b/docs/specification/signatures.md new file mode 100644 index 00000000..e88d90c5 --- /dev/null +++ b/docs/specification/signatures.md @@ -0,0 +1,977 @@ + + +# Message Signatures + +This specification defines how UCP messages are cryptographically signed to +ensure authenticity and integrity across all transports (REST, MCP) and +directions (requests and responses). + +## Overview + +UCP message signatures protect against: + +* **Impersonation** — Attackers sending messages claiming to be legitimate + participants +* **Tampering** — Modification of message contents in transit +* **Replay attacks** — Captured messages resent to different endpoints or at + different times +* **Method/endpoint confusion** — Signed payloads replayed with different + HTTP methods or to different paths + +### Architecture + +UCP uses a layered signature architecture that maximizes cryptographic reuse +while allowing protocol-appropriate signature formats: + +```text +┌─────────────────────────────────────────────────────────────────┐ +│ SHARED FOUNDATION │ +├─────────────────────────────────────────────────────────────────┤ +│ Canonicalization: JCS (RFC 8785) │ +│ Algorithms: ES256 (required), ES384, ES512 │ +│ Key Format: JWK (RFC 7517) │ +│ Key Discovery: signing_keys[] in /.well-known/ucp │ +│ Replay Protection: idempotency-key (business layer) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┴───────────────┐ + ▼ ▼ +┌─────────────────────────┐ ┌─────────────────────────┐ +│ REST BINDING │ │ MCP BINDING │ +├─────────────────────────┤ ├─────────────────────────┤ +│ Format: RFC 9421 │ │ Format: RFC 7515 App F │ +│ Headers: │ │ Location: │ +│ Signature │ │ meta.signature │ +│ Signature-Input │ │ meta.idempotency-key │ +│ UCP-Content-Digest-JCS│ │ meta.ucp-agent │ +└─────────────────────────┘ └─────────────────────────┘ +``` + +## Shared Foundation + +The following cryptographic primitives are shared across all UCP transports +and signature contexts. + +### JSON Canonicalization (JCS) + +All JSON payloads **MUST** be canonicalized using **JSON Canonicalization +Scheme (JCS)** as defined in +[RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785) before signing. + +JCS produces a deterministic, byte-for-byte identical representation of JSON +data, ensuring signatures can be verified regardless of: + +* Whitespace differences +* Key ordering variations +* Unicode normalization +* Number formatting + +**Canonicalization Rules:** + +1. Object keys sorted lexicographically (by UTF-16 code units) +2. No insignificant whitespace +3. Numbers in shortest IEEE 754 representation +4. Strings use minimal escape sequences + +**Example:** + +```json +// Input (any formatting) +{ + "checkout": { + "buyer": {"email": "alice@example.com"}, + "line_items": [{"id": "prod_123", "quantity": 2, "price": 1000}] + } +} + +// JCS output (deterministic) +{"checkout":{"buyer":{"email":"alice@example.com"},"line_items":[{"id":"prod_123","price":1000,"quantity":2}]}} +``` + +### Signature Algorithms + +All signatures **MUST** use one of the following algorithms: + +| Algorithm | Curve | Hash | Required | Description | +| :-------- | :------ | :------ | :------- | :----------------------------- | +| `ES256` | P-256 | SHA-256 | Yes | ECDSA (**RECOMMENDED**) | +| `ES384` | P-384 | SHA-384 | No | ECDSA with higher security | +| `ES512` | P-521 | SHA-512 | No | ECDSA with highest security | + +Implementations **MUST** support `ES256`. Support for `ES384` and `ES512` is +**OPTIONAL**. + +**Security Note:** ES256 provides approximately 128 bits of security, which is +sufficient for commercial applications. Use ES384 or ES512 for higher security +requirements. + +### Key Format (JWK) + +Public keys **MUST** be represented using **JSON Web Key (JWK)** format as +defined in [RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517). + +**EC Key Structure:** + +| Field | Type | Required | Description | +| :---- | :----- | :------- | :--------------------------------------- | +| `kid` | string | Yes | Key ID (referenced in signatures) | +| `kty` | string | Yes | Key type (`EC` for elliptic curve) | +| `crv` | string | Yes* | Curve name (`P-256`, `P-384`, `P-521`) | +| `x` | string | Yes* | X coordinate (base64url encoded) | +| `y` | string | Yes* | Y coordinate (base64url encoded) | +| `use` | string | No | Key usage (`sig` for signing) | +| `alg` | string | No | Algorithm (`ES256`, `ES384`, `ES512`) | + +\* Required for EC keys + +**Example:** + +```json +{ + "kid": "key-2024-01-15", + "kty": "EC", + "crv": "P-256", + "x": "WKn-ZIGevcwGIyyrzFoZNBdaq9_TsqzGl96oc0CWuis", + "y": "y77t-RvAHRKTsSGdIYUfweuOvwrvDD-Q3Hv5J0fSKbE", + "use": "sig", + "alg": "ES256" +} +``` + +### Key Discovery + +Public keys are published in the `signing_keys` array of the party's UCP +profile at `/.well-known/ucp`. + +**Business Profile:** + +```json +{ + "ucp": { ... }, + "signing_keys": [ + { + "kid": "merchant-2026", + "kty": "EC", + "crv": "P-256", + "x": "WKn-ZIGevcwGIyyrzFoZNBdaq9_TsqzGl96oc0CWuis", + "y": "y77t-RvAHRKTsSGdIYUfweuOvwrvDD-Q3Hv5J0fSKbE", + "alg": "ES256" + } + ] +} +``` + +**Platform Profile:** + +```json +{ + "ucp": { ... }, + "signing_keys": [ + { + "kid": "platform-2026", + "kty": "EC", + "crv": "P-256", + "x": "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", + "y": "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM", + "alg": "ES256" + } + ] +} +``` + +**Key Lookup:** + +1. Extract `kid` (key ID) from signature header/parameter +2. Fetch signer's UCP profile from `/.well-known/ucp` +3. Search `signing_keys[]` for matching `kid` +4. Use the corresponding public key for verification + +### Profile Trust Model + +**Profile URLs** identify the signer and locate their public keys. Implementations +**MUST** validate profile URLs to prevent attacks where malicious actors use +attacker-controlled profiles. + +**Validation Requirements:** + +1. **HTTPS required** — Profile URLs **MUST** use `https://` scheme +2. **Well-known path** — URL **MUST** end with `/.well-known/ucp` +3. **No open redirects** — Reject profiles that redirect to different domains +4. **Domain binding** — The profile domain identifies the organization + +Profile trust is typically established through: + +* **Pre-registration** — Platform/business exchange profile URLs during onboarding +* **Capability negotiation** — Profile URL discovered from partner's profile +* **Allowlists** — Implementations MAY maintain explicit allowlists of trusted profiles + +**Example Allowlist Check:** + +```text +validate_profile_url(url, allowlist): + // Parse and validate URL structure + parsed = parse_url(url) + if parsed.scheme != "https": + return error("invalid_profile_url") + if not parsed.path.endsWith("/.well-known/ucp"): + return error("invalid_profile_url") + + // Check against allowlist (if configured) + if allowlist and parsed.host not in allowlist: + return error("profile_not_trusted") + + return success() +``` + +**Profile Caching:** + +Implementations **SHOULD** cache fetched profiles to reduce latency and network +load. Recommended cache policy: + +* **TTL:** 5-15 minutes for normal operations +* **Stale-while-revalidate:** Accept stale profile during background refresh +* **Force refresh:** On signature verification failure with unknown `kid` +* **No cache:** For key compromise scenarios (see Key Rotation) + +### Key Rotation + +To rotate keys without service interruption: + +1. **Add new key** — Publish new key in `signing_keys[]` alongside existing keys +2. **Start signing** — Begin signing with the new key +3. **Grace period** — Continue accepting signatures from old keys (minimum 7 days) +4. **Remove old key** — Remove the old key from `signing_keys[]` + +**Recommendations:** + +* Rotate keys every 90 days +* Support multiple active keys during transitions +* Signers: use newest key +* Verifiers: accept any key in `signing_keys[]` + +**Key Compromise Response:** + +1. Immediately remove compromised key from profile +2. Add new key with different `kid` +3. Reject all signatures made with compromised key + +## REST Binding + +For HTTP REST transport, UCP uses +[RFC 9421 (HTTP Message Signatures)](https://www.rfc-editor.org/rfc/rfc9421). + +### Headers + +| Header | Direction | Required | Description | +| :----------------------- | :--------------- | :------- | :----------------------------------- | +| `Signature-Input` | Request/Response | Yes | Describes signed components | +| `Signature` | Request/Response | Yes | Contains signature value | +| `UCP-Content-Digest-JCS` | Request/Response | Cond.* | JCS-canonicalized body digest | + +\* Required when request/response has a body + +**Why `UCP-Content-Digest-JCS`?** RFC 9530's `Content-Digest` hashes raw bytes. +UCP uses JCS canonicalization for JSON bodies, requiring a distinct header to +indicate the different semantics. + +### REST Request Signing + +**Signed Components:** + +| Component | Required | Description | +| :----------------------- | :------- | :-------------------------------------- | +| `@method` | Yes | HTTP method (GET, POST, etc.) | +| `@path` | Yes | Request path | +| `@query` | Cond.* | Query string (if present) | +| `idempotency-key` | Cond.** | Idempotency header (state-changing ops) | +| `ucp-content-digest-jcs` | Cond.*** | Body digest (if body present) | +| `content-type` | Cond.*** | Content-Type (if body present) | + +\* Required if request has query parameters +\** Required for POST, PUT, DELETE, PATCH +\*** Required if request has a body + +**Signature Generation:** + +```text +sign_rest_request(method, path, query, body, idempotency_key, private_key, kid): + // 1. Compute body digest (if body present) + if body: + canonical = jcs_canonicalize(body) + digest = sha256(canonical) + digest_header = "sha-256=:" + base64(digest) + ":" + + // 2. Build component list + components = ["@method", "@path"] + if query: components.append("@query") + if idempotency_key: components.append("idempotency-key") + if body: components.extend(["ucp-content-digest-jcs", "content-type"]) + + // 3. Build signature base (RFC 9421) + signature_base = build_signature_base( + components=components, + method=method, + path=path, + query=query, + headers={ + "idempotency-key": idempotency_key, + "ucp-content-digest-jcs": digest_header, + "content-type": "application/json" + }, + keyid=kid + ) + + // 4. Sign + signature = ecdsa_sign(signature_base, private_key) + + // 5. Return headers + return { + "Idempotency-Key": idempotency_key, + "UCP-Content-Digest-JCS": digest_header, + "Signature-Input": format_signature_input(components, kid), + "Signature": "sig1=:" + base64(signature) + ":" + } +``` + +**Complete Request Example:** + +```http +POST /checkout-sessions HTTP/1.1 +Host: merchant.example.com +Content-Type: application/json +Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 +UCP-Content-Digest-JCS: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: +Signature-Input: sig1=("@method" "@path" "idempotency-key" "ucp-content-digest-jcs" "content-type");keyid="platform-2026" +Signature: sig1=:MEUCIQDTxNq8h7LGHpvVZQp1iHkFp9+3N8Mxk2zH1wK4YuVN8w...: + +{"checkout":{"line_items":[{"id":"prod_123","quantity":2}]}} +``` + +**GET Request Example (no body, no idempotency):** + +```http +GET /checkout-sessions/chk_123 HTTP/1.1 +Host: merchant.example.com +Signature-Input: sig1=("@method" "@path");keyid="platform-2026" +Signature: sig1=:MEQCIBx7kL9nM2oP5qR8sT1uV4wX6yZaB3cD...: +``` + +### REST Response Signing + +Response signatures use `@status` instead of `@method`: + +**Signed Components:** + +| Component | Required | Description | +| :----------------------- | :------- | :------------------------------- | +| `@status` | Yes | HTTP status code (200, 201, etc.)| +| `ucp-content-digest-jcs` | Cond.* | Body digest (if body present) | +| `content-type` | Cond.* | Content-Type (if body present) | + +\* Required if response has a body + +**Complete Response Example:** + +```http +HTTP/1.1 201 Created +Content-Type: application/json +UCP-Content-Digest-JCS: sha-256=:Y5fK8nLmPqRsT3vWxYzAbCdEfGhIjKlMnO...: +Signature-Input: sig1=("@status" "ucp-content-digest-jcs" "content-type");created=1738617601;keyid="merchant-2026" +Signature: sig1=:MFQCIH7kL9nM2oP5qR8sT1uV4wX6yZaB3cD...: + +{"checkout":{"id":"chk_123","status":"ready_for_complete"}} +``` + +**Response Signature Generation:** + +Response signing mirrors request signing with `@status` replacing `@method`: + +```text +sign_rest_response(status, body, private_key, kid): + // 1. Compute body digest (if body present) + if body: + canonical = jcs_canonicalize(body) + digest = sha256(canonical) + digest_header = "sha-256=:" + base64(digest) + ":" + + // 2. Build signature base (RFC 9421) + signature_base = build_signature_base( + components=["@status", "ucp-content-digest-jcs", "content-type"], + status=status, + headers={"ucp-content-digest-jcs": digest_header, "content-type": "application/json"}, + created=current_timestamp(), + keyid=kid + ) + + // 3. Sign + signature = ecdsa_sign(signature_base, private_key) + + // 4. Return headers + return { + "UCP-Content-Digest-JCS": digest_header, + "Signature-Input": 'sig1=("@status" "ucp-content-digest-jcs" "content-type");created=...;keyid="..."', + "Signature": "sig1=:" + base64(signature) + ":" + } +``` + +### REST Request Verification + +**Determining Signer's Profile URL:** + +The signer's profile URL is obtained from the `UCP-Agent` header, which uses +[RFC 8941 Dictionary](https://www.rfc-editor.org/rfc/rfc8941#section-3.2) syntax: + +```text +UCP-Agent: profile="https://platform.example/.well-known/ucp" +``` + +**Parsing Rules:** + +1. Parse as RFC 8941 Dictionary +2. Extract the `profile` key (REQUIRED) +3. Value MUST be a quoted string containing an HTTPS URL +4. URL MUST point to `/.well-known/ucp` at the signer's domain +5. Reject non-HTTPS URLs + +**Example:** + +```text +// Header +UCP-Agent: profile="https://platform.example/.well-known/ucp" + +// Parsed +profile_url = "https://platform.example/.well-known/ucp" +``` + +**Applicability:** + +* **Platform → Business requests:** Profile URL from `UCP-Agent` header +* **Business → Platform webhooks:** Profile URL from `UCP-Agent` header + +```text +verify_rest_request(request): + // 1. Parse Signature-Input + sig_input = parse_signature_input(request.headers["Signature-Input"]) + keyid = sig_input.keyid + components = sig_input.components + + // 2. Fetch signer's public key + profile_url = get_profile_url_from_ucp_agent(request.headers["UCP-Agent"]) + validate_profile_url(profile_url) + profile = fetch_profile(profile_url) + public_key = find_key_by_kid(profile.signing_keys, keyid) + if not public_key: + return error("key_not_found") + + // 3. Verify body digest (if body present) + if "ucp-content-digest-jcs" in components: + canonical = jcs_canonicalize(request.body) + expected = "sha-256=:" + base64(sha256(canonical)) + ":" + if request.headers["UCP-Content-Digest-JCS"] != expected: + return error("digest_mismatch") + + // 4. Reconstruct signature base + signature_base = build_signature_base( + components, request.method, request.path, request.query, + request.headers, keyid + ) + + // 5. Verify signature + signature = parse_signature(request.headers["Signature"]) + if not ecdsa_verify(signature_base, signature, public_key): + return error("signature_invalid") + + return success() + + // Note: Replay protection handled by idempotency keys in request payload +``` + +### REST Response Verification + +Response verification mirrors request verification with `@status` replacing +`@method`: + +```text +verify_rest_response(response, signer_profile_url): + // 1. Parse Signature-Input + sig_input = parse_signature_input(response.headers["Signature-Input"]) + keyid = sig_input.keyid + components = sig_input.components + + // 2. Fetch signer's public key + profile = fetch_profile(signer_profile_url) + public_key = find_key_by_kid(profile.signing_keys, keyid) + if not public_key: + return error("key_not_found") + + // 3. Verify body digest (if body present) + if "ucp-content-digest-jcs" in components: + canonical = jcs_canonicalize(response.body) + expected = "sha-256=:" + base64(sha256(canonical)) + ":" + if response.headers["UCP-Content-Digest-JCS"] != expected: + return error("digest_mismatch") + + // 4. Reconstruct signature base + signature_base = build_signature_base( + components, response.status, + response.headers, keyid + ) + + // 5. Verify signature + signature = parse_signature(response.headers["Signature"]) + if not ecdsa_verify(signature_base, signature, public_key): + return error("signature_invalid") + + return success() +``` + +### Replay Protection + +UCP handles replay protection at the **business layer** through idempotency keys, +not at the signature layer. This provides separation of concerns: + +| Layer | Responsibility | +| :---- | :------------- | +| **Signature** | Authentication (who), Integrity (what) | +| **Idempotency** | Safe retries, Replay protection | + +**How it works:** + +1. State-changing operations include an `idempotency-key` in the request +2. The idempotency key is part of the signed payload +3. Attackers cannot modify the key without invalidating the signature +4. Duplicate requests return cached responses (no new side effects) + +**Transport-specific placement:** + +| Transport | Location | Signed via | +| :-------- | :------------------------- | :--------------------------- | +| REST | `Idempotency-Key` header | Include in signed components | +| MCP | `meta.idempotency-key` | JCS-canonicalized payload | + +**REST Example:** + +```http +POST /checkout-sessions HTTP/1.1 +Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 +Signature-Input: sig1=("@method" "@path" "idempotency-key" ...);keyid="platform-2026" +Signature: sig1=:MEUCIQD...: +``` + +**MCP Example:** + +```json +{"meta": {"idempotency-key": "550e8400-e29b-41d4-a716-446655440000", "signature": "...", ...}, ...} +``` + +**Idempotency Key Requirements:** + +| Requirement | Value | +| :---------- | :---- | +| **Entropy** | Minimum 128 bits (e.g., UUID v4, 22+ char alphanumeric) | +| **Uniqueness** | Per-client, per-operation type | +| **Server storage** | Minimum 24 hours, recommended 48 hours | +| **On duplicate** | Return cached response, do not re-execute | +| **On storage failure** | Fail closed (reject request with 503) | + +**Note:** The RFC 9421 `created` parameter is **OPTIONAL**. UCP does not require +timestamps for signature validity. Key rotation (removing compromised keys from +`signing_keys`) provides the mechanism for invalidating old signatures. + +### When Signatures Are Required + +**Requests:** All UCP REST requests **MUST** include signatures. + +**Responses:** Signatures are **REQUIRED** for: + +* Order webhook notifications +* Payment authorization responses +* Checkout completion responses + +Signatures are **OPTIONAL** for: + +* Cart operations (low-value, synchronous) +* Catalog queries (read-only) +* Error responses (4xx, 5xx) + +## MCP Binding + +For MCP (JSON-RPC) transport, UCP uses **JWS Detached Content** format as +defined in [RFC 7515 Appendix F](https://datatracker.ietf.org/doc/html/rfc7515#appendix-F). + +### Signature Location + +MCP uses JSON-RPC with `tools/call` method to invoke UCP tools. The layering is: + +```text +JSON-RPC (transport) → MCP (tools/call) → UCP (tool arguments) +``` + +Signatures are included in the `meta` object within `params.arguments` (requests) +or `result` (responses). The signed payload is the **entire JSON-RPC message** +with only `meta.signature` removed. + +**Request:** + +```json +{ + "jsonrpc": "2.0", + "id": 42, + "method": "tools/call", + "params": { + "name": "create_checkout", + "arguments": { + "meta": { + "ucp-agent": {"profile": "https://platform.example/.well-known/ucp"}, + "signature": "eyJhbGciOiJFUzI1NiIsImtpZCI6InBsYXRmb3JtLTIwMjUifQ..MEUCIQD...", + "idempotency-key": "req_abc123" + }, + "checkout": { ... } + } + } +} +``` + +**Response:** + +```json +{ + "jsonrpc": "2.0", + "id": 42, + "result": { + "content": [{"type": "text", "text": "..."}], + "structuredContent": { + "meta": { + "signature": "eyJhbGciOiJFUzI1NiIsImtpZCI6Im1lcmNoYW50LTIwMjUifQ..MFQCIH..." + }, + "checkout": { ... } + } + } +} +``` + +### Meta Fields + +| Field | Type | Required | Description | +| :---------------- | :----- | :------- | :------------------------------------ | +| `ucp-agent` | object | Yes* | Signer identity (see below) | +| `signature` | string | Yes | Detached JWS (`header..signature`) | +| `idempotency-key` | string | Cond.** | Unique key for replay protection | + +\* Required for requests; responses use session context for signer identity +\** Required for state-changing operations (POST, PUT, DELETE, PATCH equivalents) + +**Replay Protection:** + +Replay protection is handled through **idempotency keys** in the `meta` object. +Since the entire message (minus `meta.signature`) is signed, attackers cannot +modify the idempotency key without invalidating the signature. + +See [Replay Protection](#replay-protection) for details. + +### Detached JWS Format + +The `signature` field contains a JWS with detached content in the format: + +```text +.. +``` + +The double dot (`..`) indicates the payload is transmitted separately (as the +JSON-RPC message itself). + +**JWS Header Claims:** + +| Claim | Type | Required | Description | +| :---- | :----- | :------- | :------------------------------------ | +| `alg` | string | Yes | Algorithm (`ES256`, `ES384`, `ES512`) | +| `kid` | string | Yes | Key ID referencing `signing_keys` | + +### MCP Request Signing + +The signed payload is the **entire JSON-RPC message** with only `meta.signature` +removed. This approach: + +* Signs exactly what's sent on the wire (minus the signature itself) +* Keeps `idempotency-key` and `ucp-agent` in their natural location inside `meta` +* Works with MCP's `tools/call` pattern + +**Signed Payload (wire format minus `meta.signature`):** + +```json +{ + "jsonrpc": "2.0", + "id": 42, + "method": "tools/call", + "params": { + "name": "create_checkout", + "arguments": { + "meta": { + "ucp-agent": {"profile": "https://platform.example/.well-known/ucp"}, + "idempotency-key": "req_abc123" + }, + "checkout": { ... } + } + } +} +``` + +**Signature Generation:** + +```text +sign_mcp_request(message, private_key, kid): + // 1. Build signed payload (entire message, excluding meta.signature) + signed_payload = deep_copy(message) + delete signed_payload.params.arguments.meta.signature // Remove if present + + // 2. JCS-canonicalize + canonical = jcs_canonicalize(signed_payload) + + // 3. Create JWS header + header = {"alg": "ES256", "kid": kid} + header_b64 = base64url(json(header)) + + // 4. Sign (header + canonical payload) + signing_input = header_b64 + "." + base64url(canonical) + signature = ecdsa_sign(signing_input, private_key) + + // 5. Create detached JWS and add to message + detached_jws = header_b64 + ".." + base64url(signature) + message.params.arguments.meta.signature = detached_jws + + return message +``` + +### MCP Response Signing + +Response signing follows the same pattern: sign the entire JSON-RPC message +with only `meta.signature` removed. For MCP responses, `meta` is inside +`result.structuredContent`. + +**Signed Payload (wire format minus `meta.signature`):** + +```json +{ + "jsonrpc": "2.0", + "id": 42, + "result": { + "content": [{"type": "text", "text": "..."}], + "structuredContent": { + "meta": {}, + "checkout": { ... } + } + } +} +``` + +Note: Response `meta` only contains `signature`. The `idempotency-key` is a +request-side concept; responses are inherently tied to their originating request. + +**Signature Generation:** + +```text +sign_mcp_response(message, private_key, kid): + // 1. Ensure meta exists + sc = message.result.structuredContent + sc.meta = sc.meta || {} + + // 2. Build signed payload (entire message, excluding meta.signature) + signed_payload = deep_copy(message) + delete signed_payload.result.structuredContent.meta.signature + + // 3. JCS-canonicalize + canonical = jcs_canonicalize(signed_payload) + + // 4. Create JWS header + header = {"alg": "ES256", "kid": kid} + header_b64 = base64url(json(header)) + + // 5. Sign (header + canonical payload) + signing_input = header_b64 + "." + base64url(canonical) + signature = ecdsa_sign(signing_input, private_key) + + // 6. Create detached JWS and add to message + detached_jws = header_b64 + ".." + base64url(signature) + sc.meta.signature = detached_jws + + return message +``` + +### MCP Request Verification + +```text +verify_mcp_request(message): + // 1. Extract signature from meta (inside params.arguments) + meta = message.params.arguments.meta + detached_jws = meta.signature + + // 2. Parse JWS header + [header_b64, _, signature_b64] = detached_jws.split(".") + header = json(base64url_decode(header_b64)) + kid = header.kid + alg = header.alg + + // 3. Fetch signer's public key + profile_url = meta["ucp-agent"].profile + validate_profile_url(profile_url) + profile = fetch_profile(profile_url) + public_key = find_key_by_kid(profile.signing_keys, kid) + if not public_key: + return error("key_not_found") + + // 4. Reconstruct signed payload (message minus meta.signature) + signed_payload = deep_copy(message) + delete signed_payload.params.arguments.meta.signature + + // 5. JCS-canonicalize + canonical = jcs_canonicalize(signed_payload) + + // 6. Verify signature + signing_input = header_b64 + "." + base64url(canonical) + signature = base64url_decode(signature_b64) + if not ecdsa_verify(signing_input, signature, public_key, alg): + return error("signature_invalid") + + return success() + + // Note: Replay protection handled by idempotency keys at business layer +``` + +### MCP Response Verification + +**Determining the Signer's Profile URL:** + +For MCP responses, the verifier must know the responder's profile URL. This is +established through the session context: + +* **Platform verifying business response:** Use the business profile URL from + capability negotiation (the platform already fetched it to initiate the session) +* **Business verifying platform response:** Use the platform profile URL from + the `ucp-agent` in the original request + +The `signer_profile_url` parameter below represents this pre-established context. + +```text +verify_mcp_response(message, signer_profile_url): + // 1. Extract signature from result.structuredContent.meta + meta = message.result.structuredContent.meta + detached_jws = meta.signature + + // 2. Parse JWS header + [header_b64, _, signature_b64] = detached_jws.split(".") + header = json(base64url_decode(header_b64)) + kid = header.kid + alg = header.alg + + // 3. Fetch signer's public key + profile = fetch_profile(signer_profile_url) + public_key = find_key_by_kid(profile.signing_keys, kid) + if not public_key: + return error("key_not_found") + + // 4. Reconstruct signed payload (message minus meta.signature) + signed_payload = deep_copy(message) + delete signed_payload.result.structuredContent.meta.signature + + // 5. JCS-canonicalize + canonical = jcs_canonicalize(signed_payload) + + // 6. Verify signature + signing_input = header_b64 + "." + base64url(canonical) + signature = base64url_decode(signature_b64) + if not ecdsa_verify(signing_input, signature, public_key, alg): + return error("signature_invalid") + + return success() +``` + +### When Signatures Are Required + +**Requests:** All UCP MCP requests **MUST** include signatures in `meta`. + +**Responses:** Signatures are **REQUIRED** for: + +* Checkout completion responses + +Signatures are **OPTIONAL** for: + +* Cart operations +* Catalog queries + +## Error Handling + +Signature verification errors use standard UCP error codes. See +[Error Handling](overview.md#error-handling) in the specification overview for +the complete error code registry and transport bindings. + +**Signature-specific errors:** + +| Code | HTTP | Description | +| :---------------------- | :--- | :--------------------------------------------------- | +| `signature_missing` | 401 | Required signature header/field not present | +| `signature_invalid` | 401 | Signature verification failed | +| `key_not_found` | 401 | Key ID not found in signer's `signing_keys` | +| `digest_mismatch` | 400 | Body digest doesn't match `UCP-Content-Digest-JCS` | +| `algorithm_unsupported` | 400 | Signature algorithm not supported | + +**Profile-related errors** (also used for capability negotiation): + +| Code | HTTP | Description | +| :---------------------- | :--- | :--------------------------------------------------- | +| `invalid_profile_url` | 400 | Profile URL malformed or invalid scheme | +| `profile_unreachable` | 424 | Unable to fetch signer's profile | +| `profile_not_trusted` | 403 | Profile URL not in trusted allowlist | + +**Note:** Replay protection is handled at the business layer through idempotency +keys, not at the signature layer. Duplicate requests return cached responses +rather than signature errors. + +### REST Error Response + +```http +HTTP/1.1 401 Unauthorized +Content-Type: application/json + +{ + "code": "signature_invalid", + "content": "Request signature verification failed for key kid=platform-2026" +} +``` + +### MCP Error Response + +```json +{ + "jsonrpc": "2.0", + "id": 42, + "error": { + "code": -32000, + "message": "Signature verification failed", + "data": { + "code": "signature_invalid", + "content": "Signature verification failed for key kid=platform-2026" + } + } +} +``` + +## References + +* [RFC 7515](https://datatracker.ietf.org/doc/html/rfc7515) — JSON Web Signature (JWS) +* [RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517) — JSON Web Key (JWK) +* [RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785) — JSON Canonicalization Scheme (JCS) +* [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) — HTTP Message Signatures diff --git a/mkdocs.yml b/mkdocs.yml index 6686c99c..d0acaaf3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -54,9 +54,10 @@ nav: - Processor Tokenizer: specification/examples/processor-tokenizer-payment-handler.md - Platform Tokenizer: specification/examples/platform-tokenizer-payment-handler.md - Encrypted Credential: specification/examples/encrypted-credential-handler.md - - Schema Authoring: documentation/schema-authoring.md - - Reference: specification/reference.md + - Signatures: specification/signatures.md - Versioning: specification/versioning.md + - Authoring: documentation/schema-authoring.md + - Reference: specification/reference.md - UCP and AP2: documentation/ucp-and-ap2.md - Roadmap: documentation/roadmap.md @@ -197,6 +198,8 @@ plugins: - documentation/roadmap.md Specification Overview: - specification/overview.md + Message Signatures: + - specification/signatures.md Checkout Capability: - specification/checkout.md - specification/checkout-rest.md diff --git a/source/services/shopping/openapi.json b/source/services/shopping/openapi.json index 54282406..ca5d14a7 100644 --- a/source/services/shopping/openapi.json +++ b/source/services/shopping/openapi.json @@ -31,7 +31,13 @@ "$ref": "#/components/parameters/x_api_key" }, { - "$ref": "#/components/parameters/request_signature" + "$ref": "#/components/parameters/signature" + }, + { + "$ref": "#/components/parameters/signature_input" + }, + { + "$ref": "#/components/parameters/ucp_content_digest_jcs" }, { "$ref": "#/components/parameters/idempotency_key" @@ -70,8 +76,14 @@ "201": { "description": "Checkout session created", "headers": { - "X-Detached-JWT": { - "$ref": "#/components/headers/x_detached_jwt" + "Signature": { + "$ref": "#/components/headers/signature" + }, + "Signature-Input": { + "$ref": "#/components/headers/signature_input" + }, + "UCP-Content-Digest-JCS": { + "$ref": "#/components/headers/ucp_content_digest_jcs" } }, "content": { @@ -101,7 +113,10 @@ "$ref": "#/components/parameters/x_api_key" }, { - "$ref": "#/components/parameters/request_signature" + "$ref": "#/components/parameters/signature" + }, + { + "$ref": "#/components/parameters/signature_input" }, { "$ref": "#/components/parameters/request_id" @@ -129,8 +144,14 @@ "200": { "description": "Checkout session retrieved", "headers": { - "X-Detached-JWT": { - "$ref": "#/components/headers/x_detached_jwt" + "Signature": { + "$ref": "#/components/headers/signature" + }, + "Signature-Input": { + "$ref": "#/components/headers/signature_input" + }, + "UCP-Content-Digest-JCS": { + "$ref": "#/components/headers/ucp_content_digest_jcs" } }, "content": { @@ -155,7 +176,13 @@ "$ref": "#/components/parameters/x_api_key" }, { - "$ref": "#/components/parameters/request_signature" + "$ref": "#/components/parameters/signature" + }, + { + "$ref": "#/components/parameters/signature_input" + }, + { + "$ref": "#/components/parameters/ucp_content_digest_jcs" }, { "$ref": "#/components/parameters/idempotency_key" @@ -194,8 +221,14 @@ "200": { "description": "Checkout session updated", "headers": { - "X-Detached-JWT": { - "$ref": "#/components/headers/x_detached_jwt" + "Signature": { + "$ref": "#/components/headers/signature" + }, + "Signature-Input": { + "$ref": "#/components/headers/signature_input" + }, + "UCP-Content-Digest-JCS": { + "$ref": "#/components/headers/ucp_content_digest_jcs" } }, "content": { @@ -225,7 +258,13 @@ "$ref": "#/components/parameters/x_api_key" }, { - "$ref": "#/components/parameters/request_signature" + "$ref": "#/components/parameters/signature" + }, + { + "$ref": "#/components/parameters/signature_input" + }, + { + "$ref": "#/components/parameters/ucp_content_digest_jcs" }, { "$ref": "#/components/parameters/idempotency_key" @@ -279,8 +318,14 @@ "200": { "description": "Checkout session completed", "headers": { - "X-Detached-JWT": { - "$ref": "#/components/headers/x_detached_jwt" + "Signature": { + "$ref": "#/components/headers/signature" + }, + "Signature-Input": { + "$ref": "#/components/headers/signature_input" + }, + "UCP-Content-Digest-JCS": { + "$ref": "#/components/headers/ucp_content_digest_jcs" } }, "content": { @@ -308,7 +353,13 @@ "$ref": "#/components/parameters/x_api_key" }, { - "$ref": "#/components/parameters/request_signature" + "$ref": "#/components/parameters/signature" + }, + { + "$ref": "#/components/parameters/signature_input" + }, + { + "$ref": "#/components/parameters/ucp_content_digest_jcs" }, { "$ref": "#/components/parameters/idempotency_key" @@ -339,8 +390,14 @@ "200": { "description": "Checkout session canceled", "headers": { - "X-Detached-JWT": { - "$ref": "#/components/headers/x_detached_jwt" + "Signature": { + "$ref": "#/components/headers/signature" + }, + "Signature-Input": { + "$ref": "#/components/headers/signature_input" + }, + "UCP-Content-Digest-JCS": { + "$ref": "#/components/headers/ucp_content_digest_jcs" } }, "content": { @@ -362,7 +419,9 @@ "parameters": [ { "$ref": "#/components/parameters/authorization" }, { "$ref": "#/components/parameters/x_api_key" }, - { "$ref": "#/components/parameters/request_signature" }, + { "$ref": "#/components/parameters/signature" }, + { "$ref": "#/components/parameters/signature_input" }, + { "$ref": "#/components/parameters/ucp_content_digest_jcs" }, { "$ref": "#/components/parameters/idempotency_key" }, { "$ref": "#/components/parameters/request_id" }, { "$ref": "#/components/parameters/user_agent" }, @@ -383,7 +442,9 @@ "201": { "description": "Cart created", "headers": { - "X-Detached-JWT": { "$ref": "#/components/headers/x_detached_jwt" } + "Signature": { "$ref": "#/components/headers/signature" }, + "Signature-Input": { "$ref": "#/components/headers/signature_input" }, + "UCP-Content-Digest-JCS": { "$ref": "#/components/headers/ucp_content_digest_jcs" } }, "content": { "application/json": { @@ -405,7 +466,8 @@ "parameters": [ { "$ref": "#/components/parameters/authorization" }, { "$ref": "#/components/parameters/x_api_key" }, - { "$ref": "#/components/parameters/request_signature" }, + { "$ref": "#/components/parameters/signature" }, + { "$ref": "#/components/parameters/signature_input" }, { "$ref": "#/components/parameters/request_id" }, { "$ref": "#/components/parameters/user_agent" }, { "$ref": "#/components/parameters/ucp_agent" }, @@ -418,7 +480,9 @@ "200": { "description": "Cart retrieved", "headers": { - "X-Detached-JWT": { "$ref": "#/components/headers/x_detached_jwt" } + "Signature": { "$ref": "#/components/headers/signature" }, + "Signature-Input": { "$ref": "#/components/headers/signature_input" }, + "UCP-Content-Digest-JCS": { "$ref": "#/components/headers/ucp_content_digest_jcs" } }, "content": { "application/json": { @@ -438,7 +502,9 @@ "parameters": [ { "$ref": "#/components/parameters/authorization" }, { "$ref": "#/components/parameters/x_api_key" }, - { "$ref": "#/components/parameters/request_signature" }, + { "$ref": "#/components/parameters/signature" }, + { "$ref": "#/components/parameters/signature_input" }, + { "$ref": "#/components/parameters/ucp_content_digest_jcs" }, { "$ref": "#/components/parameters/idempotency_key" }, { "$ref": "#/components/parameters/request_id" }, { "$ref": "#/components/parameters/user_agent" }, @@ -460,7 +526,9 @@ "200": { "description": "Cart updated", "headers": { - "X-Detached-JWT": { "$ref": "#/components/headers/x_detached_jwt" } + "Signature": { "$ref": "#/components/headers/signature" }, + "Signature-Input": { "$ref": "#/components/headers/signature_input" }, + "UCP-Content-Digest-JCS": { "$ref": "#/components/headers/ucp_content_digest_jcs" } }, "content": { "application/json": { @@ -482,7 +550,9 @@ "parameters": [ { "$ref": "#/components/parameters/authorization" }, { "$ref": "#/components/parameters/x_api_key" }, - { "$ref": "#/components/parameters/request_signature" }, + { "$ref": "#/components/parameters/signature" }, + { "$ref": "#/components/parameters/signature_input" }, + { "$ref": "#/components/parameters/ucp_content_digest_jcs" }, { "$ref": "#/components/parameters/idempotency_key" }, { "$ref": "#/components/parameters/request_id" }, { "$ref": "#/components/parameters/user_agent" }, @@ -496,7 +566,9 @@ "200": { "description": "Cart cancelled", "headers": { - "X-Detached-JWT": { "$ref": "#/components/headers/x_detached_jwt" } + "Signature": { "$ref": "#/components/headers/signature" }, + "Signature-Input": { "$ref": "#/components/headers/signature_input" }, + "UCP-Content-Digest-JCS": { "$ref": "#/components/headers/ucp_content_digest_jcs" } }, "content": { "application/json": { @@ -515,7 +587,10 @@ "summary": "Order Event Webhook", "description": "Merchant sends order lifecycle events to the platform's registered webhook URL. The platform provides the webhook URL during partner onboarding.", "parameters": [ - { "$ref": "#/components/parameters/request_signature" }, + { "$ref": "#/components/parameters/signature" }, + { "$ref": "#/components/parameters/signature_input" }, + { "$ref": "#/components/parameters/ucp_content_digest_jcs" }, + { "$ref": "#/components/parameters/ucp_agent" }, { "$ref": "#/components/parameters/x_api_key" } ], "requestBody": { @@ -604,14 +679,42 @@ }, "description": "Authenticates the platform with a reusable api key allocated to the platform by the business." }, + "signature": { + "name": "Signature", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "RFC 9421 HTTP Message Signature. Contains the signature value in the format `sig1=::`." + }, + "signature_input": { + "name": "Signature-Input", + "in": "header", + "required": true, + "schema": { + "type": "string" + }, + "description": "RFC 9421 Signature-Input header. Describes signed components, timestamp, and key ID. Format: `sig1=(\"@method\" \"@path\" ...);created=;keyid=\"\"`." + }, + "ucp_content_digest_jcs": { + "name": "UCP-Content-Digest-JCS", + "in": "header", + "required": false, + "schema": { + "type": "string" + }, + "description": "JCS-canonicalized body digest per RFC 8785. Required for requests/responses with a body. Format: `sha-256=::`." + }, "request_signature": { "name": "Request-Signature", "in": "header", - "required": true, + "required": false, + "deprecated": true, "schema": { "type": "string" }, - "description": "Ensure the authenticity and integrity of an HTTP message." + "description": "DEPRECATED: Use Signature + Signature-Input headers instead. Will be removed 2026-08-01." }, "idempotency_key": { "name": "Idempotency-Key", @@ -649,7 +752,7 @@ "schema": { "type": "string" }, - "description": "Identifies the UCP agent making the call. All requests MUST include the UCP-Agent header containing the platform profile URI using Dictionary Structured Field syntax (RFC 8941). Format: profile=\"https://platform.example/profile\"." + "description": "Identifies the UCP agent making the call. All requests MUST include the UCP-Agent header containing the signer's profile URI using RFC 8941 Dictionary syntax. The URL MUST point to /.well-known/ucp. Format: profile=\"https://example.com/.well-known/ucp\"." }, "content_type": { "name": "Content-Type", @@ -689,12 +792,26 @@ } }, "headers": { - "x_detached_jwt": { + "signature": { + "required": false, + "schema": { + "type": "string" + }, + "description": "RFC 9421 HTTP Message Signature for response. Contains the signature value in the format `sig1=::`." + }, + "signature_input": { + "required": false, + "schema": { + "type": "string" + }, + "description": "RFC 9421 Signature-Input header for response. Describes signed components, timestamp, and key ID." + }, + "ucp_content_digest_jcs": { "required": false, "schema": { "type": "string" }, - "description": "Optional detached JWT signature for the response body. Verifies the response came from the server." + "description": "JCS-canonicalized body digest per RFC 8785. Format: `sha-256=::`." } }, "schemas": { diff --git a/source/services/shopping/openrpc.json b/source/services/shopping/openrpc.json index fc061701..c347234a 100644 --- a/source/services/shopping/openrpc.json +++ b/source/services/shopping/openrpc.json @@ -42,6 +42,10 @@ "type": "string", "format": "uuid", "description": "Unique key for retry safety. Maps to HTTP Idempotency-Key header." + }, + "signature": { + "type": "string", + "description": "Detached JWS signature (RFC 7515 Appendix F) in format `..`. See Message Signatures specification." } } } @@ -174,6 +178,11 @@ "summary": "Create a cart", "description": "Create a new cart session.", "params": [ + { + "name": "meta", + "required": true, + "schema": {"$ref": "#/components/schemas/meta"} + }, { "name": "cart", "required": true, @@ -189,6 +198,11 @@ "name": "get_cart", "summary": "Get cart", "params": [ + { + "name": "meta", + "required": true, + "schema": {"$ref": "#/components/schemas/meta"} + }, { "name": "id", "required": true, @@ -204,6 +218,11 @@ "name": "update_cart", "summary": "Update cart", "params": [ + { + "name": "meta", + "required": true, + "schema": {"$ref": "#/components/schemas/meta"} + }, { "name": "id", "required": true, From 1d75fb6971a61149f4d2f6aa1fa2f1eb809121b4 Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Wed, 4 Feb 2026 21:39:42 -0800 Subject: [PATCH 2/3] clarify algorithm requirements vs usage guidance - Separate "Implementation requirements" (MUST verify ES256) from "Usage guidance" (SHOULD use ES256 for compatibility) - Removed "Signers: use newest key" - no way to determine key age from JWK spec, and verifiers accept any key in signing_keys[] --- docs/specification/signatures.md | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/docs/specification/signatures.md b/docs/specification/signatures.md index e88d90c5..18d6fddc 100644 --- a/docs/specification/signatures.md +++ b/docs/specification/signatures.md @@ -104,20 +104,24 @@ data, ensuring signatures can be verified regardless of: ### Signature Algorithms -All signatures **MUST** use one of the following algorithms: +UCP supports ECDSA signatures with the following algorithms: -| Algorithm | Curve | Hash | Required | Description | -| :-------- | :------ | :------ | :------- | :----------------------------- | -| `ES256` | P-256 | SHA-256 | Yes | ECDSA (**RECOMMENDED**) | -| `ES384` | P-384 | SHA-384 | No | ECDSA with higher security | -| `ES512` | P-521 | SHA-512 | No | ECDSA with highest security | +| Algorithm | Curve | Hash | +| :-------- | :------ | :------ | +| `ES256` | P-256 | SHA-256 | +| `ES384` | P-384 | SHA-384 | +| `ES512` | P-521 | SHA-512 | -Implementations **MUST** support `ES256`. Support for `ES384` and `ES512` is -**OPTIONAL**. +**Implementation requirements:** -**Security Note:** ES256 provides approximately 128 bits of security, which is -sufficient for commercial applications. Use ES384 or ES512 for higher security -requirements. +* All implementations **MUST** support verifying `ES256` signatures +* Support for `ES384` and `ES512` is **OPTIONAL** + +**Usage guidance:** + +* Signers **SHOULD** use `ES256` for maximum compatibility +* Signers **MAY** use `ES384` or `ES512` when both parties support them +* The algorithm is indicated by the `alg` field in the signing key's JWK ### Key Format (JWK) @@ -260,7 +264,6 @@ To rotate keys without service interruption: * Rotate keys every 90 days * Support multiple active keys during transitions -* Signers: use newest key * Verifiers: accept any key in `signing_keys[]` **Key Compromise Response:** From 93b7131b6efdc589793e7bfed1e3cf3e333ee38c Mon Sep 17 00:00:00 2001 From: Ilya Grigorik Date: Fri, 6 Feb 2026 09:05:18 -0800 Subject: [PATCH 3/3] support multiple auth mechanisms; unify on RFC9421 This restructures UCP's signature specification based on working group discussion. Key changes: Multiple authentication mechanisms: - HTTP Message Signatures, API keys, OAuth 2.0, mTLS, choose whichever - Businesses choose what fits their security model and integration patterns - Implementations SHOULD maintain allowlists of trusted profiles RFC 9421 for all HTTP transports: - REST and MCP (streamable HTTP) now use the same signing mechanism - Signature + Signature-Input headers replace the deprecated Request-Signature - Covered components: @method, @path, content-digest, ucp-agent, idempotency-key Content-Digest (raw bytes) replaces JCS canonicalization: - Request signatures hash raw body bytes per RFC 9530 - Simpler implementation, no JSON canonicalization complexity - JCS retained only for AP2 mandates --- .cspell/custom-words.txt | 1 + docs/specification/ap2-mandates.md | 20 +- docs/specification/checkout-mcp.md | 92 ++--- docs/specification/checkout-rest.md | 20 +- docs/specification/order.md | 29 +- docs/specification/overview.md | 2 +- docs/specification/signatures.md | 542 ++++++-------------------- source/services/shopping/openapi.json | 62 ++- 8 files changed, 233 insertions(+), 535 deletions(-) diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index b5b1d64a..de4517fa 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -79,6 +79,7 @@ repudiable schemas sdjwt shopify +streamable superfences upsell upsells diff --git a/docs/specification/ap2-mandates.md b/docs/specification/ap2-mandates.md index 8a67ccfe..5df9d352 100644 --- a/docs/specification/ap2-mandates.md +++ b/docs/specification/ap2-mandates.md @@ -131,8 +131,8 @@ This extension uses the cryptographic primitives defined in the * **Key Format:** JWK ([RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517)) * **Key Discovery:** `signing_keys[]` in `/.well-known/ucp` -See [Message Signatures - Shared Foundation](signatures.md#shared-foundation) -for complete details on algorithms, key format, and key rotation. +See [Message Signatures](signatures.md) for complete details on algorithms, +key format, and key rotation. ### Business Authorization @@ -216,8 +216,20 @@ selective disclosure, key binding) is defined by the All JSON payloads **MUST** be canonicalized using **JSON Canonicalization Scheme (JCS)** per [RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785). -See [Message Signatures - JSON Canonicalization](signatures.md#json-canonicalization-jcs) -for canonicalization rules. + +**Why JCS for Mandates?** UCP request signatures use `Content-Digest` (raw +bytes) without canonicalization — the request is signed and verified +immediately over the same HTTP connection. Mandates are different: + +* **Durability** — Mandates are stored as evidence of user consent. They may + be retrieved and verified days or months later. +* **Cross-system transmission** — Mandates pass through multiple systems + (platform → business → PSP → card network) that may re-serialize JSON. +* **Reproducibility** — Any party must reconstruct the exact signed bytes + from the logical JSON content, regardless of serialization differences. + +JCS ensures that semantically identical JSON produces byte-identical output, +making signatures reproducible across implementations and time. **AP2-Specific Rule:** When computing the business's `merchant_authorization` signature, exclude the `ap2` field entirely. This ensures future AP2 fields diff --git a/docs/specification/checkout-mcp.md b/docs/specification/checkout-mcp.md index a03156cd..65042d72 100644 --- a/docs/specification/checkout-mcp.md +++ b/docs/specification/checkout-mcp.md @@ -652,53 +652,49 @@ as JSON-RPC `result` with `structuredContent` containing the UCP envelope and ## Message Signing -All checkout operations **MUST** include message signatures per the +Platforms **SHOULD** authenticate agents when using MCP transport. When using +HTTP Message Signatures, all checkout operations follow the [Message Signatures](signatures.md) specification. ### Request Signing -Platforms **MUST** sign all requests using JWS Detached Content (RFC 7515 -Appendix F). The signature is placed in `meta.signature`: +UCP's MCP transport uses **streamable HTTP**, allowing the same RFC 9421 +signature mechanism as REST. The signature is applied at the HTTP layer: -| Field | Required | Description | -| :--------------------- | :------- | :--------------------------------- | -| `meta.signature` | Yes | Detached JWS (`header..signature`) | -| `meta.ucp-agent` | Yes | Signer identity | -| `meta.idempotency-key` | Cond.* | Unique key for replay protection | +| Header | Required | Description | +| :----------------------- | :------- | :--------------------------------------- | +| `Signature-Input` | Yes | Describes signed components | +| `Signature` | Yes | Contains the signature value | +| `Content-Digest` | Yes | SHA-256 hash of request body | +| `UCP-Agent` | Yes | Signer identity (profile URL) | +| `Idempotency-Key` | Cond.* | Unique key for replay protection | \* Required for `complete_checkout` and `cancel_checkout` **Example Signed Request:** -```json -{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "complete_checkout", - "arguments": { - "meta": { - "ucp-agent": {"profile": "https://platform.example/.well-known/ucp"}, - "signature": "eyJhbGciOiJFUzI1NiIsImtpZCI6InBsYXRmb3JtLTIwMjUifQ..MEUCIQD...", - "idempotency-key": "550e8400-e29b-41d4-a716-446655440000" - }, - "id": "checkout_abc123", - "checkout": {"payment": {...}} - } - } -} +```http +POST /mcp HTTP/1.1 +Host: business.example.com +Content-Type: application/json +UCP-Agent: profile="https://platform.example/.well-known/ucp" +Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 +Content-Digest: sha-256=:RK/0qy18MlBSVnWgjwz6lZEWjP/lF5HF9bvEF8FabDg=: +Signature-Input: sig1=("@method" "@path" "content-digest" "content-type" "ucp-agent" "idempotency-key");keyid="platform-2026" +Signature: sig1=:MEUCIQDXyK9N3p5Rt...: + +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"complete_checkout","arguments":{"id":"checkout_abc123","checkout":{"payment":{...}}}}} ``` -The signed payload is the entire JSON-RPC message with only `meta.signature` -removed, then JCS-canonicalized. +The `Content-Digest` binds the JSON-RPC body to the signature. No JSON +canonicalization is required. -See [Message Signatures - MCP Request Signing](signatures.md#mcp-request-signing) -for the complete signing algorithm. +See [Message Signatures - MCP Transport](signatures.md#mcp-transport) +for details. ### Response Signing -Response signatures are **REQUIRED** for: +Response signatures are **RECOMMENDED** for: * `complete_checkout` responses (order confirmation) @@ -708,24 +704,18 @@ Response signatures are **OPTIONAL** for: **Example Signed Response:** -```json -{ - "jsonrpc": "2.0", - "id": 1, - "result": { - "content": [{"type": "text", "text": "..."}], - "structuredContent": { - "meta": { - "signature": "eyJhbGciOiJFUzI1NiIsImtpZCI6Im1lcmNoYW50LTIwMjUifQ..MFQCIH..." - }, - "checkout": {"id": "checkout_abc123", "status": "completed", ...} - } - } -} +```http +HTTP/1.1 200 OK +Content-Type: application/json +Content-Digest: sha-256=:Y5fK8nLmPqRsT3vWxYzAbCdEfGhIjKlMnO...: +Signature-Input: sig1=("@status" "content-digest" "content-type");keyid="merchant-2026" +Signature: sig1=:MFQCIH7kL9nM2oP5qR8sT1uV4wX6yZaB3cD...: + +{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"..."}],"structuredContent":{"checkout":{"id":"checkout_abc123","status":"completed"}}}} ``` -See [Message Signatures - MCP Response Signing](signatures.md#mcp-response-signing) -for the complete signing algorithm. +See [Message Signatures - REST Response Signing](signatures.md#rest-response-signing) +for the signing algorithm (identical for MCP over HTTP). ## Conformance @@ -738,8 +728,12 @@ A conforming MCP transport implementation **MUST**: `messages` array. 5. Validate tool inputs against UCP schemas. 6. Support HTTP transport with streaming. -7. Sign all requests per [Message Signatures](signatures.md) specification. -8. Verify signatures on incoming requests before processing. + +A conforming implementation **SHOULD**: + +1. Authenticate agents using one of the supported mechanisms (API keys, OAuth, + mTLS, or HTTP Message Signatures per [Message Signatures](signatures.md)). +2. Verify authentication on incoming requests before processing. ## Implementation diff --git a/docs/specification/checkout-rest.md b/docs/specification/checkout-rest.md index 93b6d6ae..e68aeb00 100644 --- a/docs/specification/checkout-rest.md +++ b/docs/specification/checkout-rest.md @@ -1284,18 +1284,19 @@ with HTTP 200 and the UCP envelope containing `messages`: ## Message Signing -All checkout operations **MUST** include message signatures per the +Platforms **SHOULD** authenticate agents when using REST transport. When using +HTTP Message Signatures, checkout operations follow the [Message Signatures](signatures.md) specification. ### Request Signing -Platforms **MUST** sign all requests using RFC 9421 HTTP Message Signatures: +Platforms using HTTP Message Signatures **SHOULD** sign requests using RFC 9421: | Header | Required | Description | | :----------------------- | :------- | :--------------------------------------- | | `Signature-Input` | Yes | Describes signed components | | `Signature` | Yes | Contains the signature value | -| `UCP-Content-Digest-JCS` | Cond.* | JCS-canonicalized body digest | +| `Content-Digest` | Cond.* | SHA-256 hash of request body | \* Required for requests with a body (POST, PUT) @@ -1306,8 +1307,9 @@ POST /checkout-sessions HTTP/1.1 Host: merchant.example.com Content-Type: application/json UCP-Agent: profile="https://platform.example/.well-known/ucp" -UCP-Content-Digest-JCS: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: -Signature-Input: sig1=("@method" "@path" "ucp-content-digest-jcs" "content-type");created=1738617600;keyid="platform-2025" +Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 +Content-Digest: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: +Signature-Input: sig1=("@method" "@path" "idempotency-key" "content-digest" "content-type");keyid="platform-2025" Signature: sig1=:MEUCIQDTxNq8h7LGHpvVZQp1iHkFp9+3N8Mxk2zH1wK4YuVN8w...: {"line_items":[{"item":{"id":"item_123"},"quantity":2}]} @@ -1318,7 +1320,7 @@ for the complete signing algorithm. ### Response Signing -Response signatures are **REQUIRED** for: +Response signatures are **RECOMMENDED** for: * `complete_checkout` responses (order confirmation) @@ -1331,8 +1333,8 @@ Response signatures are **OPTIONAL** for: ```http HTTP/1.1 200 OK Content-Type: application/json -UCP-Content-Digest-JCS: sha-256=:Y5fK8nLmPqRsT3vWxYzAbCdEfGhIjKlMnO...: -Signature-Input: sig1=("@status" "ucp-content-digest-jcs" "content-type");created=1738617601;keyid="merchant-2025" +Content-Digest: sha-256=:Y5fK8nLmPqRsT3vWxYzAbCdEfGhIjKlMnO...: +Signature-Input: sig1=("@status" "content-digest" "content-type");keyid="merchant-2025" Signature: sig1=:MFQCIH7kL9nM2oP5qR8sT1uV4wX6yZaB3cD...: {"id":"chk_123","status":"completed","order":{"id":"ord_456"}} @@ -1353,6 +1355,8 @@ authentication is required, the REST transport **MAY** use: 3. **OAuth 2.0**: Via `Authorization: Bearer {token}` header, following [RFC 6749](https://tools.ietf.org/html/rfc6749){ target="_blank" }. 4. **Mutual TLS**: For high-security environments. +5. **HTTP Message Signatures**: Per [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) + (see [Message Signing](#message-signing) above). Businesses **MAY** require authentication for some operations while leaving others open (e.g., public checkout without authentication). diff --git a/docs/specification/order.md b/docs/specification/order.md index 84a2dc9e..34a8eb09 100644 --- a/docs/specification/order.md +++ b/docs/specification/order.md @@ -299,12 +299,12 @@ to ensure authenticity and integrity. Signatures follow the **Required Headers:** -| Header | Description | -| :----------------------- | :----------------------------------------- | -| `UCP-Agent` | Business profile URL (RFC 8941 Dictionary) | -| `Signature-Input` | Describes signed components | -| `Signature` | Contains the signature value | -| `UCP-Content-Digest-JCS` | JCS-canonicalized body digest | +| Header | Description | +| :--------------- | :----------------------------------------- | +| `UCP-Agent` | Business profile URL (RFC 8941 Dictionary) | +| `Signature-Input`| Describes signed components | +| `Signature` | Contains the signature value | +| `Content-Digest` | Body digest (RFC 9530) | **Example Webhook Request:** @@ -313,8 +313,8 @@ POST /webhooks/ucp/orders HTTP/1.1 Host: platform.example.com Content-Type: application/json UCP-Agent: profile="https://merchant.example/.well-known/ucp" -UCP-Content-Digest-JCS: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: -Signature-Input: sig1=("@method" "@path" "ucp-content-digest-jcs" "content-type");keyid="merchant-2026" +Content-Digest: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: +Signature-Input: sig1=("@method" "@path" "content-digest" "content-type");keyid="merchant-2026" Signature: sig1=:MEUCIQDTxNq8h7LGHpvVZQp1iHkFp9+3N8Mxk2zH1wK4YuVN8w...: {"id":"order_abc123","event_id":"evt_123","created_time":"2026-01-15T12:00:00Z",...} @@ -322,11 +322,10 @@ Signature: sig1=:MEUCIQDTxNq8h7LGHpvVZQp1iHkFp9+3N8Mxk2zH1wK4YuVN8w...: #### Signing (Business) -1. JCS-canonicalize the webhook payload ([RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785)) -2. Compute SHA-256 digest and set `UCP-Content-Digest-JCS` header -3. Build signature base per [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) -4. Sign using a key from `signing_keys` in the business's UCP profile -5. Set `Signature-Input` and `Signature` headers +1. Compute SHA-256 digest of the raw request body and set `Content-Digest` header +2. Build signature base per [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) +3. Sign using a key from `signing_keys` in the business's UCP profile +4. Set `Signature-Input` and `Signature` headers See [Message Signatures - REST Request Signing](signatures.md#rest-request-signing) for complete algorithm. @@ -338,7 +337,7 @@ for complete algorithm. 1. Parse `Signature-Input` to extract `keyid` and signed components 2. Fetch business's UCP profile from `/.well-known/ucp` (cache as appropriate) 3. Locate key in `signing_keys` with matching `kid` -4. Verify `UCP-Content-Digest-JCS` matches JCS-canonicalized body +4. Verify `Content-Digest` matches SHA-256 of raw body 5. Reconstruct signature base and verify signature See [Message Signatures - REST Request Verification](signatures.md#rest-request-verification) @@ -373,7 +372,7 @@ zero-downtime key rotation procedures. * **MUST** include `UCP-Agent` header with profile URL for signer identification * **MUST** sign all webhook payloads per the [Message Signatures](signatures.md) specification using RFC 9421 headers - (`Signature`, `Signature-Input`, `UCP-Content-Digest-JCS`). + (`Signature`, `Signature-Input`, `Content-Digest`). * **MUST** send "Order created" event with fully populated order entity * **MUST** send full order entity on updates (not incremental deltas) * **MUST** retry failed webhook deliveries diff --git a/docs/specification/overview.md b/docs/specification/overview.md index 41abf498..0fb3da81 100644 --- a/docs/specification/overview.md +++ b/docs/specification/overview.md @@ -605,7 +605,7 @@ These failure types require different handling: | `signature_missing` | Required signature header/field not present | 401 | -32000 | | `signature_invalid` | Signature verification failed | 401 | -32000 | | `key_not_found` | Key ID not found in signer's `signing_keys` | 401 | -32000 | -| `digest_mismatch` | Body digest doesn't match `UCP-Content-Digest-JCS` | 400 | -32600 | +| `digest_mismatch` | Body digest doesn't match `Content-Digest` header | 400 | -32600 | | `algorithm_unsupported`| Signature algorithm not supported | 400 | -32600 | See [Message Signatures](signatures.md) for signature verification details. diff --git a/docs/specification/signatures.md b/docs/specification/signatures.md index 18d6fddc..8ecb603b 100644 --- a/docs/specification/signatures.md +++ b/docs/specification/signatures.md @@ -17,12 +17,23 @@ # Message Signatures This specification defines how UCP messages are cryptographically signed to -ensure authenticity and integrity across all transports (REST, MCP) and -directions (requests and responses). +ensure authenticity and integrity. ## Overview -UCP message signatures protect against: +Businesses **SHOULD** authenticate agents to prevent impersonation and ensure +message integrity. UCP supports multiple authentication mechanisms: + +* **API Keys** — Pre-shared secrets exchanged out-of-band +* **OAuth 2.0** — Client credentials or other OAuth flows +* **mTLS** — Mutual TLS with client certificates +* **HTTP Message Signatures** — Cryptographic signatures per RFC 9421 (this spec) + +HTTP Message Signatures are particularly valuable for **permissionless agent +onboarding** — merchants can declaratively trust agents by their advertised +public keys without negotiating shared secrets. + +When using HTTP Message Signatures, they protect against: * **Impersonation** — Attackers sending messages claiming to be legitimate participants @@ -34,73 +45,42 @@ UCP message signatures protect against: ### Architecture -UCP uses a layered signature architecture that maximizes cryptographic reuse -while allowing protocol-appropriate signature formats: +UCP uses HTTP Message Signatures ([RFC 9421](https://www.rfc-editor.org/rfc/rfc9421)) +for all HTTP-based transports: ```text ┌─────────────────────────────────────────────────────────────────┐ │ SHARED FOUNDATION │ ├─────────────────────────────────────────────────────────────────┤ -│ Canonicalization: JCS (RFC 8785) │ +│ Signature Format: RFC 9421 (HTTP Message Signatures) │ +│ Body Digest: RFC 9530 (Content-Digest, raw bytes) │ │ Algorithms: ES256 (required), ES384, ES512 │ │ Key Format: JWK (RFC 7517) │ │ Key Discovery: signing_keys[] in /.well-known/ucp │ │ Replay Protection: idempotency-key (business layer) │ └─────────────────────────────────────────────────────────────────┘ │ - ┌───────────────┴───────────────┐ - ▼ ▼ -┌─────────────────────────┐ ┌─────────────────────────┐ -│ REST BINDING │ │ MCP BINDING │ -├─────────────────────────┤ ├─────────────────────────┤ -│ Format: RFC 9421 │ │ Format: RFC 7515 App F │ -│ Headers: │ │ Location: │ -│ Signature │ │ meta.signature │ -│ Signature-Input │ │ meta.idempotency-key │ -│ UCP-Content-Digest-JCS│ │ meta.ucp-agent │ -└─────────────────────────┘ └─────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ HTTP TRANSPORTS │ +├─────────────────────────────────────────────────────────────────┤ +│ REST API: Standard HTTP requests │ +│ MCP: Streamable HTTP transport (JSON-RPC over HTTP) │ +├─────────────────────────────────────────────────────────────────┤ +│ Headers: │ +│ Signature-Input (describes signed components) │ +│ Signature (contains signature value) │ +│ Content-Digest (body hash, raw bytes) │ +└─────────────────────────────────────────────────────────────────┘ ``` -## Shared Foundation - -The following cryptographic primitives are shared across all UCP transports -and signature contexts. - -### JSON Canonicalization (JCS) +**Note:** UCP specifies streamable HTTP for MCP transport, replacing SSE-based +transports. This allows the same RFC 9421 signature mechanism to apply uniformly +across all UCP transports. -All JSON payloads **MUST** be canonicalized using **JSON Canonicalization -Scheme (JCS)** as defined in -[RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785) before signing. - -JCS produces a deterministic, byte-for-byte identical representation of JSON -data, ensuring signatures can be verified regardless of: - -* Whitespace differences -* Key ordering variations -* Unicode normalization -* Number formatting - -**Canonicalization Rules:** - -1. Object keys sorted lexicographically (by UTF-16 code units) -2. No insignificant whitespace -3. Numbers in shortest IEEE 754 representation -4. Strings use minimal escape sequences - -**Example:** - -```json -// Input (any formatting) -{ - "checkout": { - "buyer": {"email": "alice@example.com"}, - "line_items": [{"id": "prod_123", "quantity": 2, "price": 1000}] - } -} +## Shared Foundation -// JCS output (deterministic) -{"checkout":{"buyer":{"email":"alice@example.com"},"line_items":[{"id":"prod_123","price":1000,"quantity":2}]}} -``` +The following cryptographic primitives are shared across all UCP HTTP transports. ### Signature Algorithms @@ -221,7 +201,7 @@ Profile trust is typically established through: * **Pre-registration** — Platform/business exchange profile URLs during onboarding * **Capability negotiation** — Profile URL discovered from partner's profile -* **Allowlists** — Implementations MAY maintain explicit allowlists of trusted profiles +* **Allowlists** — Implementations SHOULD maintain explicit allowlists of trusted profiles **Example Allowlist Check:** @@ -279,30 +259,37 @@ For HTTP REST transport, UCP uses ### Headers -| Header | Direction | Required | Description | -| :----------------------- | :--------------- | :------- | :----------------------------------- | -| `Signature-Input` | Request/Response | Yes | Describes signed components | -| `Signature` | Request/Response | Yes | Contains signature value | -| `UCP-Content-Digest-JCS` | Request/Response | Cond.* | JCS-canonicalized body digest | +| Header | Direction | Required | Description | +| :---------------- | :--------------- | :------- | :------------------------------------ | +| `Signature-Input` | Request/Response | Yes | Describes signed components | +| `Signature` | Request/Response | Yes | Contains signature value | +| `Content-Digest` | Request/Response | Cond.* | SHA-256 hash of request/response body | \* Required when request/response has a body -**Why `UCP-Content-Digest-JCS`?** RFC 9530's `Content-Digest` hashes raw bytes. -UCP uses JCS canonicalization for JSON bodies, requiring a distinct header to -indicate the different semantics. +`Content-Digest` follows [RFC 9530](https://www.rfc-editor.org/rfc/rfc9530) and +hashes the raw body bytes. This binds the message body to the signature without +requiring JSON canonicalization. Implementations **MUST** use `sha-256`. For +durable artifacts requiring canonicalization, see +[AP2 Mandates - Canonicalization](ap2-mandates.md#canonicalization). + +**Intermediary Warning:** Proxies, API gateways, and other intermediaries +**MUST NOT** re-serialize JSON bodies, as this would invalidate the signature. +The `Content-Digest` is computed over raw bytes; any modification breaks +verification. ### REST Request Signing **Signed Components:** -| Component | Required | Description | -| :----------------------- | :------- | :-------------------------------------- | -| `@method` | Yes | HTTP method (GET, POST, etc.) | -| `@path` | Yes | Request path | -| `@query` | Cond.* | Query string (if present) | -| `idempotency-key` | Cond.** | Idempotency header (state-changing ops) | -| `ucp-content-digest-jcs` | Cond.*** | Body digest (if body present) | -| `content-type` | Cond.*** | Content-Type (if body present) | +| Component | Required | Description | +| :---------------- | :------- | :-------------------------------------- | +| `@method` | Yes | HTTP method (GET, POST, etc.) | +| `@path` | Yes | Request path | +| `@query` | Cond.* | Query string (if present) | +| `idempotency-key` | Cond.** | Idempotency header (state-changing ops) | +| `content-digest` | Cond.*** | Body digest (if body present) | +| `content-type` | Cond.*** | Content-Type (if body present) | \* Required if request has query parameters \** Required for POST, PUT, DELETE, PATCH @@ -311,18 +298,17 @@ indicate the different semantics. **Signature Generation:** ```text -sign_rest_request(method, path, query, body, idempotency_key, private_key, kid): +sign_rest_request(method, path, query, body_bytes, idempotency_key, private_key, kid): // 1. Compute body digest (if body present) - if body: - canonical = jcs_canonicalize(body) - digest = sha256(canonical) + if body_bytes: + digest = sha256(body_bytes) // Hash raw bytes, no canonicalization digest_header = "sha-256=:" + base64(digest) + ":" // 2. Build component list components = ["@method", "@path"] if query: components.append("@query") if idempotency_key: components.append("idempotency-key") - if body: components.extend(["ucp-content-digest-jcs", "content-type"]) + if body: components.extend(["content-digest", "content-type"]) // 3. Build signature base (RFC 9421) signature_base = build_signature_base( @@ -332,7 +318,7 @@ sign_rest_request(method, path, query, body, idempotency_key, private_key, kid): query=query, headers={ "idempotency-key": idempotency_key, - "ucp-content-digest-jcs": digest_header, + "content-digest": digest_header, "content-type": "application/json" }, keyid=kid @@ -344,7 +330,7 @@ sign_rest_request(method, path, query, body, idempotency_key, private_key, kid): // 5. Return headers return { "Idempotency-Key": idempotency_key, - "UCP-Content-Digest-JCS": digest_header, + "Content-Digest": digest_header, "Signature-Input": format_signature_input(components, kid), "Signature": "sig1=:" + base64(signature) + ":" } @@ -357,8 +343,8 @@ POST /checkout-sessions HTTP/1.1 Host: merchant.example.com Content-Type: application/json Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 -UCP-Content-Digest-JCS: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: -Signature-Input: sig1=("@method" "@path" "idempotency-key" "ucp-content-digest-jcs" "content-type");keyid="platform-2026" +Content-Digest: sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=: +Signature-Input: sig1=("@method" "@path" "idempotency-key" "content-digest" "content-type");keyid="platform-2026" Signature: sig1=:MEUCIQDTxNq8h7LGHpvVZQp1iHkFp9+3N8Mxk2zH1wK4YuVN8w...: {"checkout":{"line_items":[{"id":"prod_123","quantity":2}]}} @@ -379,11 +365,11 @@ Response signatures use `@status` instead of `@method`: **Signed Components:** -| Component | Required | Description | -| :----------------------- | :------- | :------------------------------- | -| `@status` | Yes | HTTP status code (200, 201, etc.)| -| `ucp-content-digest-jcs` | Cond.* | Body digest (if body present) | -| `content-type` | Cond.* | Content-Type (if body present) | +| Component | Required | Description | +| :--------------- | :------- | :-------------------------------- | +| `@status` | Yes | HTTP status code (200, 201, etc.) | +| `content-digest` | Cond.* | Body digest (if body present) | +| `content-type` | Cond.* | Content-Type (if body present) | \* Required if response has a body @@ -392,8 +378,8 @@ Response signatures use `@status` instead of `@method`: ```http HTTP/1.1 201 Created Content-Type: application/json -UCP-Content-Digest-JCS: sha-256=:Y5fK8nLmPqRsT3vWxYzAbCdEfGhIjKlMnO...: -Signature-Input: sig1=("@status" "ucp-content-digest-jcs" "content-type");created=1738617601;keyid="merchant-2026" +Content-Digest: sha-256=:Y5fK8nLmPqRsT3vWxYzAbCdEfGhIjKlMnO...: +Signature-Input: sig1=("@status" "content-digest" "content-type");created=1738617601;keyid="merchant-2026" Signature: sig1=:MFQCIH7kL9nM2oP5qR8sT1uV4wX6yZaB3cD...: {"checkout":{"id":"chk_123","status":"ready_for_complete"}} @@ -404,18 +390,17 @@ Signature: sig1=:MFQCIH7kL9nM2oP5qR8sT1uV4wX6yZaB3cD...: Response signing mirrors request signing with `@status` replacing `@method`: ```text -sign_rest_response(status, body, private_key, kid): +sign_rest_response(status, body_bytes, private_key, kid): // 1. Compute body digest (if body present) - if body: - canonical = jcs_canonicalize(body) - digest = sha256(canonical) + if body_bytes: + digest = sha256(body_bytes) // Hash raw bytes, no canonicalization digest_header = "sha-256=:" + base64(digest) + ":" // 2. Build signature base (RFC 9421) signature_base = build_signature_base( - components=["@status", "ucp-content-digest-jcs", "content-type"], + components=["@status", "content-digest", "content-type"], status=status, - headers={"ucp-content-digest-jcs": digest_header, "content-type": "application/json"}, + headers={"content-digest": digest_header, "content-type": "application/json"}, created=current_timestamp(), keyid=kid ) @@ -425,8 +410,8 @@ sign_rest_response(status, body, private_key, kid): // 4. Return headers return { - "UCP-Content-Digest-JCS": digest_header, - "Signature-Input": 'sig1=("@status" "ucp-content-digest-jcs" "content-type");created=...;keyid="..."', + "Content-Digest": digest_header, + "Signature-Input": 'sig1=("@status" "content-digest" "content-type");created=...;keyid="..."', "Signature": "sig1=:" + base64(signature) + ":" } ``` @@ -481,10 +466,9 @@ verify_rest_request(request): return error("key_not_found") // 3. Verify body digest (if body present) - if "ucp-content-digest-jcs" in components: - canonical = jcs_canonicalize(request.body) - expected = "sha-256=:" + base64(sha256(canonical)) + ":" - if request.headers["UCP-Content-Digest-JCS"] != expected: + if "content-digest" in components: + expected = "sha-256=:" + base64(sha256(request.body_bytes)) + ":" + if request.headers["Content-Digest"] != expected: return error("digest_mismatch") // 4. Reconstruct signature base @@ -522,10 +506,9 @@ verify_rest_response(response, signer_profile_url): return error("key_not_found") // 3. Verify body digest (if body present) - if "ucp-content-digest-jcs" in components: - canonical = jcs_canonicalize(response.body) - expected = "sha-256=:" + base64(sha256(canonical)) + ":" - if response.headers["UCP-Content-Digest-JCS"] != expected: + if "content-digest" in components: + expected = "sha-256=:" + base64(sha256(response.body_bytes)) + ":" + if response.headers["Content-Digest"] != expected: return error("digest_mismatch") // 4. Reconstruct signature base @@ -559,14 +542,9 @@ not at the signature layer. This provides separation of concerns: 3. Attackers cannot modify the key without invalidating the signature 4. Duplicate requests return cached responses (no new side effects) -**Transport-specific placement:** - -| Transport | Location | Signed via | -| :-------- | :------------------------- | :--------------------------- | -| REST | `Idempotency-Key` header | Include in signed components | -| MCP | `meta.idempotency-key` | JCS-canonicalized payload | +**Idempotency Key Placement:** -**REST Example:** +The `Idempotency-Key` header is included in the signed components: ```http POST /checkout-sessions HTTP/1.1 @@ -575,12 +553,6 @@ Signature-Input: sig1=("@method" "@path" "idempotency-key" ...);keyid="platform- Signature: sig1=:MEUCIQD...: ``` -**MCP Example:** - -```json -{"meta": {"idempotency-key": "550e8400-e29b-41d4-a716-446655440000", "signature": "...", ...}, ...} -``` - **Idempotency Key Requirements:** | Requirement | Value | @@ -591,15 +563,18 @@ Signature: sig1=:MEUCIQD...: | **On duplicate** | Return cached response, do not re-execute | | **On storage failure** | Fail closed (reject request with 503) | -**Note:** The RFC 9421 `created` parameter is **OPTIONAL**. UCP does not require -timestamps for signature validity. Key rotation (removing compromised keys from -`signing_keys`) provides the mechanism for invalidating old signatures. +**Note:** The RFC 9421 `created` parameter is **OPTIONAL**. UCP handles replay +protection at the business layer through idempotency keys, not signature timestamps. +Key rotation (removing compromised keys from `signing_keys`) provides the mechanism +for invalidating old signatures. -### When Signatures Are Required +### When Signatures Are Recommended -**Requests:** All UCP REST requests **MUST** include signatures. +**Requests:** Platforms **SHOULD** sign all requests when using HTTP Message +Signatures. Alternative authentication mechanisms (API keys, OAuth, mTLS) may +be used instead. -**Responses:** Signatures are **REQUIRED** for: +**Responses:** Signatures are **RECOMMENDED** for: * Order webhook notifications * Payment authorization responses @@ -611,309 +586,33 @@ Signatures are **OPTIONAL** for: * Catalog queries (read-only) * Error responses (4xx, 5xx) -## MCP Binding - -For MCP (JSON-RPC) transport, UCP uses **JWS Detached Content** format as -defined in [RFC 7515 Appendix F](https://datatracker.ietf.org/doc/html/rfc7515#appendix-F). - -### Signature Location - -MCP uses JSON-RPC with `tools/call` method to invoke UCP tools. The layering is: - -```text -JSON-RPC (transport) → MCP (tools/call) → UCP (tool arguments) -``` - -Signatures are included in the `meta` object within `params.arguments` (requests) -or `result` (responses). The signed payload is the **entire JSON-RPC message** -with only `meta.signature` removed. - -**Request:** - -```json -{ - "jsonrpc": "2.0", - "id": 42, - "method": "tools/call", - "params": { - "name": "create_checkout", - "arguments": { - "meta": { - "ucp-agent": {"profile": "https://platform.example/.well-known/ucp"}, - "signature": "eyJhbGciOiJFUzI1NiIsImtpZCI6InBsYXRmb3JtLTIwMjUifQ..MEUCIQD...", - "idempotency-key": "req_abc123" - }, - "checkout": { ... } - } - } -} -``` - -**Response:** - -```json -{ - "jsonrpc": "2.0", - "id": 42, - "result": { - "content": [{"type": "text", "text": "..."}], - "structuredContent": { - "meta": { - "signature": "eyJhbGciOiJFUzI1NiIsImtpZCI6Im1lcmNoYW50LTIwMjUifQ..MFQCIH..." - }, - "checkout": { ... } - } - } -} -``` - -### Meta Fields - -| Field | Type | Required | Description | -| :---------------- | :----- | :------- | :------------------------------------ | -| `ucp-agent` | object | Yes* | Signer identity (see below) | -| `signature` | string | Yes | Detached JWS (`header..signature`) | -| `idempotency-key` | string | Cond.** | Unique key for replay protection | - -\* Required for requests; responses use session context for signer identity -\** Required for state-changing operations (POST, PUT, DELETE, PATCH equivalents) - -**Replay Protection:** - -Replay protection is handled through **idempotency keys** in the `meta` object. -Since the entire message (minus `meta.signature`) is signed, attackers cannot -modify the idempotency key without invalidating the signature. - -See [Replay Protection](#replay-protection) for details. - -### Detached JWS Format - -The `signature` field contains a JWS with detached content in the format: - -```text -.. -``` - -The double dot (`..`) indicates the payload is transmitted separately (as the -JSON-RPC message itself). - -**JWS Header Claims:** - -| Claim | Type | Required | Description | -| :---- | :----- | :------- | :------------------------------------ | -| `alg` | string | Yes | Algorithm (`ES256`, `ES384`, `ES512`) | -| `kid` | string | Yes | Key ID referencing `signing_keys` | - -### MCP Request Signing - -The signed payload is the **entire JSON-RPC message** with only `meta.signature` -removed. This approach: - -* Signs exactly what's sent on the wire (minus the signature itself) -* Keeps `idempotency-key` and `ucp-agent` in their natural location inside `meta` -* Works with MCP's `tools/call` pattern - -**Signed Payload (wire format minus `meta.signature`):** - -```json -{ - "jsonrpc": "2.0", - "id": 42, - "method": "tools/call", - "params": { - "name": "create_checkout", - "arguments": { - "meta": { - "ucp-agent": {"profile": "https://platform.example/.well-known/ucp"}, - "idempotency-key": "req_abc123" - }, - "checkout": { ... } - } - } -} -``` - -**Signature Generation:** - -```text -sign_mcp_request(message, private_key, kid): - // 1. Build signed payload (entire message, excluding meta.signature) - signed_payload = deep_copy(message) - delete signed_payload.params.arguments.meta.signature // Remove if present - - // 2. JCS-canonicalize - canonical = jcs_canonicalize(signed_payload) - - // 3. Create JWS header - header = {"alg": "ES256", "kid": kid} - header_b64 = base64url(json(header)) - - // 4. Sign (header + canonical payload) - signing_input = header_b64 + "." + base64url(canonical) - signature = ecdsa_sign(signing_input, private_key) - - // 5. Create detached JWS and add to message - detached_jws = header_b64 + ".." + base64url(signature) - message.params.arguments.meta.signature = detached_jws - - return message -``` +## MCP Transport -### MCP Response Signing +UCP specifies **streamable HTTP** for MCP transport, replacing SSE-based transports. +Since MCP requests are standard HTTP requests with JSON-RPC bodies, the same +RFC 9421 signature mechanism applies: -Response signing follows the same pattern: sign the entire JSON-RPC message -with only `meta.signature` removed. For MCP responses, `meta` is inside -`result.structuredContent`. +* The `Content-Digest` header covers the JSON-RPC message body +* The `Signature-Input` and `Signature` headers provide authentication +* The `UCP-Agent` and `Idempotency-Key` headers work identically to REST -**Signed Payload (wire format minus `meta.signature`):** +**Example MCP Request with Signature:** -```json -{ - "jsonrpc": "2.0", - "id": 42, - "result": { - "content": [{"type": "text", "text": "..."}], - "structuredContent": { - "meta": {}, - "checkout": { ... } - } - } -} -``` - -Note: Response `meta` only contains `signature`. The `idempotency-key` is a -request-side concept; responses are inherently tied to their originating request. - -**Signature Generation:** - -```text -sign_mcp_response(message, private_key, kid): - // 1. Ensure meta exists - sc = message.result.structuredContent - sc.meta = sc.meta || {} - - // 2. Build signed payload (entire message, excluding meta.signature) - signed_payload = deep_copy(message) - delete signed_payload.result.structuredContent.meta.signature - - // 3. JCS-canonicalize - canonical = jcs_canonicalize(signed_payload) - - // 4. Create JWS header - header = {"alg": "ES256", "kid": kid} - header_b64 = base64url(json(header)) - - // 5. Sign (header + canonical payload) - signing_input = header_b64 + "." + base64url(canonical) - signature = ecdsa_sign(signing_input, private_key) - - // 6. Create detached JWS and add to message - detached_jws = header_b64 + ".." + base64url(signature) - sc.meta.signature = detached_jws - - return message -``` - -### MCP Request Verification - -```text -verify_mcp_request(message): - // 1. Extract signature from meta (inside params.arguments) - meta = message.params.arguments.meta - detached_jws = meta.signature - - // 2. Parse JWS header - [header_b64, _, signature_b64] = detached_jws.split(".") - header = json(base64url_decode(header_b64)) - kid = header.kid - alg = header.alg - - // 3. Fetch signer's public key - profile_url = meta["ucp-agent"].profile - validate_profile_url(profile_url) - profile = fetch_profile(profile_url) - public_key = find_key_by_kid(profile.signing_keys, kid) - if not public_key: - return error("key_not_found") - - // 4. Reconstruct signed payload (message minus meta.signature) - signed_payload = deep_copy(message) - delete signed_payload.params.arguments.meta.signature - - // 5. JCS-canonicalize - canonical = jcs_canonicalize(signed_payload) - - // 6. Verify signature - signing_input = header_b64 + "." + base64url(canonical) - signature = base64url_decode(signature_b64) - if not ecdsa_verify(signing_input, signature, public_key, alg): - return error("signature_invalid") - - return success() - - // Note: Replay protection handled by idempotency keys at business layer -``` - -### MCP Response Verification - -**Determining the Signer's Profile URL:** - -For MCP responses, the verifier must know the responder's profile URL. This is -established through the session context: - -* **Platform verifying business response:** Use the business profile URL from - capability negotiation (the platform already fetched it to initiate the session) -* **Business verifying platform response:** Use the platform profile URL from - the `ucp-agent` in the original request - -The `signer_profile_url` parameter below represents this pre-established context. - -```text -verify_mcp_response(message, signer_profile_url): - // 1. Extract signature from result.structuredContent.meta - meta = message.result.structuredContent.meta - detached_jws = meta.signature - - // 2. Parse JWS header - [header_b64, _, signature_b64] = detached_jws.split(".") - header = json(base64url_decode(header_b64)) - kid = header.kid - alg = header.alg - - // 3. Fetch signer's public key - profile = fetch_profile(signer_profile_url) - public_key = find_key_by_kid(profile.signing_keys, kid) - if not public_key: - return error("key_not_found") - - // 4. Reconstruct signed payload (message minus meta.signature) - signed_payload = deep_copy(message) - delete signed_payload.result.structuredContent.meta.signature - - // 5. JCS-canonicalize - canonical = jcs_canonicalize(signed_payload) - - // 6. Verify signature - signing_input = header_b64 + "." + base64url(canonical) - signature = base64url_decode(signature_b64) - if not ecdsa_verify(signing_input, signature, public_key, alg): - return error("signature_invalid") +```http +POST /mcp HTTP/1.1 +Host: business.example.com +Content-Type: application/json +UCP-Agent: profile="https://platform.example/.well-known/ucp" +Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 +Content-Digest: sha-256=:RK/0qy18MlBSVnWgjwz6lZEWjP/lF5HF9bvEF8FabDg=: +Signature-Input: sig1=("@method" "@path" "content-digest" "content-type" "ucp-agent" "idempotency-key");keyid="platform-2026" +Signature: sig1=:MEUCIQDXyK9N3p5Rt...: - return success() +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"complete_checkout","arguments":{"id":"chk_123","checkout":{...}}}} ``` -### When Signatures Are Required - -**Requests:** All UCP MCP requests **MUST** include signatures in `meta`. - -**Responses:** Signatures are **REQUIRED** for: - -* Checkout completion responses - -Signatures are **OPTIONAL** for: - -* Cart operations -* Catalog queries +The JSON-RPC message is the HTTP body. `Content-Digest` binds it to the signature. +No JSON canonicalization is required. ## Error Handling @@ -928,7 +627,7 @@ the complete error code registry and transport bindings. | `signature_missing` | 401 | Required signature header/field not present | | `signature_invalid` | 401 | Signature verification failed | | `key_not_found` | 401 | Key ID not found in signer's `signing_keys` | -| `digest_mismatch` | 400 | Body digest doesn't match `UCP-Content-Digest-JCS` | +| `digest_mismatch` | 400 | Body digest doesn't match `Content-Digest` header | | `algorithm_unsupported` | 400 | Signature algorithm not supported | **Profile-related errors** (also used for capability negotiation): @@ -974,7 +673,6 @@ Content-Type: application/json ## References -* [RFC 7515](https://datatracker.ietf.org/doc/html/rfc7515) — JSON Web Signature (JWS) * [RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517) — JSON Web Key (JWK) -* [RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785) — JSON Canonicalization Scheme (JCS) * [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) — HTTP Message Signatures +* [RFC 9530](https://www.rfc-editor.org/rfc/rfc9530) — Digest Fields (Content-Digest) diff --git a/source/services/shopping/openapi.json b/source/services/shopping/openapi.json index ca5d14a7..c64c9a24 100644 --- a/source/services/shopping/openapi.json +++ b/source/services/shopping/openapi.json @@ -37,7 +37,7 @@ "$ref": "#/components/parameters/signature_input" }, { - "$ref": "#/components/parameters/ucp_content_digest_jcs" + "$ref": "#/components/parameters/content_digest" }, { "$ref": "#/components/parameters/idempotency_key" @@ -82,8 +82,8 @@ "Signature-Input": { "$ref": "#/components/headers/signature_input" }, - "UCP-Content-Digest-JCS": { - "$ref": "#/components/headers/ucp_content_digest_jcs" + "Content-Digest": { + "$ref": "#/components/headers/content_digest" } }, "content": { @@ -150,8 +150,8 @@ "Signature-Input": { "$ref": "#/components/headers/signature_input" }, - "UCP-Content-Digest-JCS": { - "$ref": "#/components/headers/ucp_content_digest_jcs" + "Content-Digest": { + "$ref": "#/components/headers/content_digest" } }, "content": { @@ -182,7 +182,7 @@ "$ref": "#/components/parameters/signature_input" }, { - "$ref": "#/components/parameters/ucp_content_digest_jcs" + "$ref": "#/components/parameters/content_digest" }, { "$ref": "#/components/parameters/idempotency_key" @@ -227,8 +227,8 @@ "Signature-Input": { "$ref": "#/components/headers/signature_input" }, - "UCP-Content-Digest-JCS": { - "$ref": "#/components/headers/ucp_content_digest_jcs" + "Content-Digest": { + "$ref": "#/components/headers/content_digest" } }, "content": { @@ -264,7 +264,7 @@ "$ref": "#/components/parameters/signature_input" }, { - "$ref": "#/components/parameters/ucp_content_digest_jcs" + "$ref": "#/components/parameters/content_digest" }, { "$ref": "#/components/parameters/idempotency_key" @@ -324,8 +324,8 @@ "Signature-Input": { "$ref": "#/components/headers/signature_input" }, - "UCP-Content-Digest-JCS": { - "$ref": "#/components/headers/ucp_content_digest_jcs" + "Content-Digest": { + "$ref": "#/components/headers/content_digest" } }, "content": { @@ -359,7 +359,7 @@ "$ref": "#/components/parameters/signature_input" }, { - "$ref": "#/components/parameters/ucp_content_digest_jcs" + "$ref": "#/components/parameters/content_digest" }, { "$ref": "#/components/parameters/idempotency_key" @@ -396,8 +396,8 @@ "Signature-Input": { "$ref": "#/components/headers/signature_input" }, - "UCP-Content-Digest-JCS": { - "$ref": "#/components/headers/ucp_content_digest_jcs" + "Content-Digest": { + "$ref": "#/components/headers/content_digest" } }, "content": { @@ -421,7 +421,7 @@ { "$ref": "#/components/parameters/x_api_key" }, { "$ref": "#/components/parameters/signature" }, { "$ref": "#/components/parameters/signature_input" }, - { "$ref": "#/components/parameters/ucp_content_digest_jcs" }, + { "$ref": "#/components/parameters/content_digest" }, { "$ref": "#/components/parameters/idempotency_key" }, { "$ref": "#/components/parameters/request_id" }, { "$ref": "#/components/parameters/user_agent" }, @@ -444,7 +444,7 @@ "headers": { "Signature": { "$ref": "#/components/headers/signature" }, "Signature-Input": { "$ref": "#/components/headers/signature_input" }, - "UCP-Content-Digest-JCS": { "$ref": "#/components/headers/ucp_content_digest_jcs" } + "Content-Digest": { "$ref": "#/components/headers/content_digest" } }, "content": { "application/json": { @@ -482,7 +482,7 @@ "headers": { "Signature": { "$ref": "#/components/headers/signature" }, "Signature-Input": { "$ref": "#/components/headers/signature_input" }, - "UCP-Content-Digest-JCS": { "$ref": "#/components/headers/ucp_content_digest_jcs" } + "Content-Digest": { "$ref": "#/components/headers/content_digest" } }, "content": { "application/json": { @@ -504,7 +504,7 @@ { "$ref": "#/components/parameters/x_api_key" }, { "$ref": "#/components/parameters/signature" }, { "$ref": "#/components/parameters/signature_input" }, - { "$ref": "#/components/parameters/ucp_content_digest_jcs" }, + { "$ref": "#/components/parameters/content_digest" }, { "$ref": "#/components/parameters/idempotency_key" }, { "$ref": "#/components/parameters/request_id" }, { "$ref": "#/components/parameters/user_agent" }, @@ -528,7 +528,7 @@ "headers": { "Signature": { "$ref": "#/components/headers/signature" }, "Signature-Input": { "$ref": "#/components/headers/signature_input" }, - "UCP-Content-Digest-JCS": { "$ref": "#/components/headers/ucp_content_digest_jcs" } + "Content-Digest": { "$ref": "#/components/headers/content_digest" } }, "content": { "application/json": { @@ -552,7 +552,7 @@ { "$ref": "#/components/parameters/x_api_key" }, { "$ref": "#/components/parameters/signature" }, { "$ref": "#/components/parameters/signature_input" }, - { "$ref": "#/components/parameters/ucp_content_digest_jcs" }, + { "$ref": "#/components/parameters/content_digest" }, { "$ref": "#/components/parameters/idempotency_key" }, { "$ref": "#/components/parameters/request_id" }, { "$ref": "#/components/parameters/user_agent" }, @@ -568,7 +568,7 @@ "headers": { "Signature": { "$ref": "#/components/headers/signature" }, "Signature-Input": { "$ref": "#/components/headers/signature_input" }, - "UCP-Content-Digest-JCS": { "$ref": "#/components/headers/ucp_content_digest_jcs" } + "Content-Digest": { "$ref": "#/components/headers/content_digest" } }, "content": { "application/json": { @@ -589,7 +589,7 @@ "parameters": [ { "$ref": "#/components/parameters/signature" }, { "$ref": "#/components/parameters/signature_input" }, - { "$ref": "#/components/parameters/ucp_content_digest_jcs" }, + { "$ref": "#/components/parameters/content_digest" }, { "$ref": "#/components/parameters/ucp_agent" }, { "$ref": "#/components/parameters/x_api_key" } ], @@ -697,24 +697,14 @@ }, "description": "RFC 9421 Signature-Input header. Describes signed components, timestamp, and key ID. Format: `sig1=(\"@method\" \"@path\" ...);created=;keyid=\"\"`." }, - "ucp_content_digest_jcs": { - "name": "UCP-Content-Digest-JCS", + "content_digest": { + "name": "Content-Digest", "in": "header", "required": false, "schema": { "type": "string" }, - "description": "JCS-canonicalized body digest per RFC 8785. Required for requests/responses with a body. Format: `sha-256=::`." - }, - "request_signature": { - "name": "Request-Signature", - "in": "header", - "required": false, - "deprecated": true, - "schema": { - "type": "string" - }, - "description": "DEPRECATED: Use Signature + Signature-Input headers instead. Will be removed 2026-08-01." + "description": "Body digest per RFC 9530. Required for requests/responses with a body. Format: `sha-256=::`." }, "idempotency_key": { "name": "Idempotency-Key", @@ -806,7 +796,7 @@ }, "description": "RFC 9421 Signature-Input header for response. Describes signed components, timestamp, and key ID." }, - "ucp_content_digest_jcs": { + "content_digest": { "required": false, "schema": { "type": "string"