From e4fb7fcf239e0f94372e1fd46c53b13b1a52070e Mon Sep 17 00:00:00 2001 From: Sakina Roufid Date: Fri, 7 Aug 2026 08:52:11 -0400 Subject: [PATCH] fix!: bind signed REST responses to their request Response signatures cover only @status, content-digest and content-type. Nothing binds a response to the request's method, path, query or body, and `created` is optional and never read by the verifier, so there is no freshness bound either. A correctly signed response therefore stays valid after it is detached from the request it answered. That matters for the adversary UCP already designs against in the Intermediary Warning: something that terminates TLS and can relay bytes but cannot mint signatures, such as a CDN edge, WAF or gateway sitting in front of a Business. Two replays follow. Rollback: a signed response captured earlier is replayed verbatim against a later request for the same resource. Every covered component still matches, so the Platform accepts a stale totals or status as current. Checking the body does not help, because the body is a real, correctly signed earlier state of the correct object, and it passes the Platform side totals check in checkout.md since it was internally consistent when issued. The Business stays authoritative and settles on its own state, so this is not a path to theft. The damage is that the Platform and buyer act on stale terms, which matters for autonomous completion and for AP2 mandates formed over that data. Cross-request substitution: a signed response to one request served against a different request to the same origin, since neither method, path nor query is covered. Mostly mitigated already by self-describing bodies carrying id, but the same fix closes it for free. Use RFC 9421 section 2.4 request-response binding. Response signatures now cover @authority, @method and @path from the associated request via the `;req` parameter, plus @query and content-digest when the request carried them. Verifiers rebuild those components from the request they actually sent, not from anything the response claims. Make `created` required on response signatures with an enforced window (300s recommended). Binding alone does not bound how long a captured response can be replayed against a repeated identical request, which is the polling case above. Replay Protection read as though idempotency keys covered both directions. They are request only. That section now says so and points at the response side mechanism. BREAKING CHANGE: verifiers reject response signatures that omit the `;req` components or `created`. Signers and verifiers have to move together. --- docs/specification/checkout-mcp.md | 2 +- docs/specification/checkout-rest.md | 2 +- docs/specification/signatures.md | 130 ++++++++++++++++++++++------ 3 files changed, 105 insertions(+), 29 deletions(-) diff --git a/docs/specification/checkout-mcp.md b/docs/specification/checkout-mcp.md index 9477c0cd8..879b810d4 100644 --- a/docs/specification/checkout-mcp.md +++ b/docs/specification/checkout-mcp.md @@ -788,7 +788,7 @@ Response signatures are **OPTIONAL** for: 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-Input: sig1=("@status" "@authority";req "@method";req "@path";req "content-digest";req "content-digest" "content-type");created=1738617601;keyid="merchant-2026" Signature: sig1=:6G4i8TS6oUkGrx8KnCFUpsSPwd74...: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"..."}],"structuredContent":{"id":"checkout_abc123","status":"completed"}}} diff --git a/docs/specification/checkout-rest.md b/docs/specification/checkout-rest.md index 653851c97..d84c1c6f7 100644 --- a/docs/specification/checkout-rest.md +++ b/docs/specification/checkout-rest.md @@ -1459,7 +1459,7 @@ Response signatures are **OPTIONAL** for: 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-Input: sig1=("@status" "@authority";req "@method";req "@path";req "content-digest";req "content-digest" "content-type");created=1738617601;keyid="merchant-2026" Signature: sig1=:6G4i8TS6oUkGrx8KnCFUpsSPwd74...: {"id":"chk_123","status":"completed","order":{"id":"ord_456"}} diff --git a/docs/specification/signatures.md b/docs/specification/signatures.md index f2465bd57..fc12c2701 100644 --- a/docs/specification/signatures.md +++ b/docs/specification/signatures.md @@ -249,7 +249,9 @@ signing operation, one signature on the wire — that both a UCP verifier wanting interop with WBA-conformant verifiers; the requirements below apply to any signature carrying `tag="web-bot-auth"`. WBA interop is request-scoped: responses are signed with standard UCP signatures -(covering `@status`) and do not carry `tag="web-bot-auth"`. +(covering `@status` and the request binding described in +[REST Response Signing](#rest-response-signing)) and do not carry +`tag="web-bot-auth"`. **On the wire.** A dual-audience signature is a normal UCP signature with a few additions: it carries a `Signature-Agent` header **alongside** @@ -536,17 +538,55 @@ Signature: sig1=:6G4i8TS6oUkGrx8KnCFUpsSPwd74...: ### REST Response Signing -Response signatures use `@status` instead of `@method`: +Response signatures use `@status` instead of `@method`, and **MUST** bind +the response to the request that produced it. **Signed Components:** -| 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 +| Component | Required | Description | +| :--------------------- | :-------- | :---------------------------------------------- | +| `@status` | Yes | HTTP status code (200, 201, etc.) | +| `@authority`;`req` | Yes | Request target host (binds the serving origin) | +| `@method`;`req` | Yes | Request method (binds the operation) | +| `@path`;`req` | Yes | Request path (binds the addressed resource) | +| `@query`;`req` | Cond. `*` | Request query string (if the request had one) | +| `content-digest`;`req` | Cond. `**`| Request body digest (if the request had a body) | +| `content-digest` | Cond. `†` | Response body digest (if response has a body) | +| `content-type` | Cond. `†` | Content-Type (if response has a body) | + +* `*` Required if the request had query parameters + +* `**` Required if the request had a body + +* `†` Required if the response has a body + +**Request Binding:** The `;req` parameter is +[RFC 9421 §2.4](https://www.rfc-editor.org/rfc/rfc9421#section-2.4) +request-response signature binding: it directs the signer and verifier to +draw the named component from the **associated request** rather than from +the response. Without it, a response signature covers only the response's +own status and body, so a correctly signed response remains valid when +detached from its request. That admits two attacks that TLS does not +prevent when an intermediary terminates it (see the Intermediary Warning +in [Headers](#headers)): + +* **Rollback / stale replay.** A signed response captured earlier is + replayed verbatim in answer to a later request for the same resource. + Every covered component still matches, so the verifier accepts a stale + view (an outdated `totals`, an expired `status`) as current. Inspecting + the body does not help: the body is a genuine, correctly signed earlier + state of the correct object. + +* **Cross-request substitution.** A signed response to one request is + served in answer to a different request to the same origin, since + neither the method, the path, nor the query was covered. + +**Freshness:** For signed responses the RFC 9421 `created` parameter is +**REQUIRED**, and verifiers **MUST** reject a response whose `created` is +outside an acceptable window. Verifiers **SHOULD** use a window of 300 +seconds and **MUST NOT** accept a `created` in the future beyond their +tolerated clock skew. Request binding alone does not bound how long a +captured response can be replayed against a repeated identical request. **Complete Response Example:** @@ -556,11 +596,15 @@ required fields (`ucp`, `currency`, `line_items`, `totals`, `links`); see [Create Checkout response](checkout-rest.md#create-checkout) for the complete shape. +The signature below answers the `POST /checkout-sessions` request shown in +[REST Request Signing](#rest-request-signing); the `;req` components are +drawn from that request. + ```http HTTP/1.1 201 Created Content-Type: application/json Content-Digest: sha-256=:Y5fK8nLmPqRsT3vWxYzAbCdEfGhIjKlMnO...: -Signature-Input: sig1=("@status" "content-digest" "content-type");created=1738617601;keyid="merchant-2026" +Signature-Input: sig1=("@status" "@authority";req "@method";req "@path";req "content-digest";req "content-digest" "content-type");created=1738617601;keyid="merchant-2026" Signature: sig1=:6G4i8TS6oUkGrx8KnCFUpsSPwd74...: { @@ -572,31 +616,40 @@ Signature: sig1=:6G4i8TS6oUkGrx8KnCFUpsSPwd74...: **Response Signature Generation:** -Response signing mirrors request signing with `@status` replacing `@method`: +Response signing mirrors request signing with `@status` replacing `@method`, +plus the `;req` components drawn from the associated request: ```text -sign_rest_response(status, body_bytes, private_key, kid): +sign_rest_response(status, body_bytes, request, private_key, kid): // 1. Compute body digest (if body present) if body_bytes: digest = sha256(body_bytes) // Hash raw bytes, no canonicalization digest_header = "sha-256=:" + base64(digest) + ":" - // 2. Build signature base (RFC 9421) + // 2. Build component list. ";req" components come from the request + // this response answers (RFC 9421 section 2.4). + components = ["@status", "@authority;req", "@method;req", "@path;req"] + if request.query: components.append("@query;req") + if request.body: components.append("content-digest;req") + if body_bytes: components.extend(["content-digest", "content-type"]) + + // 3. Build signature base (RFC 9421). created is REQUIRED on responses. signature_base = build_signature_base( - components=["@status", "content-digest", "content-type"], + components=components, status=status, + request=request, // source for the ";req" components headers={"content-digest": digest_header, "content-type": "application/json"}, created=current_timestamp(), keyid=kid ) - // 3. Sign + // 4. Sign signature = sign(signature_base, private_key) // ecdsa for EC, eddsa for OKP - // 4. Return headers + // 5. Return headers return { "Content-Digest": digest_header, - "Signature-Input": 'sig1=("@status" "content-digest" "content-type");created=...;keyid="..."', + "Signature-Input": format_signature_input(components, created, kid), "Signature": "sig1=:" + base64(signature) + ":" } ``` @@ -748,7 +801,7 @@ Response verification mirrors request verification with `@status` replacing `@method`: ```text -verify_rest_response(response, signer_profile_url): +verify_rest_response(response, request, signer_profile_url): // 1. Parse Signature-Input sig_input = parse_signature_input(response.headers["Signature-Input"]) keyid = sig_input.keyid @@ -768,12 +821,24 @@ verify_rest_response(response, signer_profile_url): return skip_signature("algorithm_unsupported") // 2b. Enforce covered-component requirements for responses (all regimes). - // No method/idempotency to bind, but the body still MUST be covered. - required = ["@status"] + // The response body MUST be covered, and the response MUST be bound to + // the request it answers, so it cannot be detached and replayed against + // a later or different request (RFC 9421 section 2.4). + required = ["@status", "@authority;req", "@method;req", "@path;req"] + if request.query: required += ["@query;req"] + if request.has_body: required += ["content-digest;req"] if response.has_body: required += ["content-digest", "content-type"] for component in required: if component not in components: - return skip_signature("signature_invalid") + return skip_signature("coverage_insufficient") + + // 2c. Enforce freshness. created is REQUIRED on responses; a bound + // response can still be replayed against a repeated identical request + // until it ages out. + if sig_input.created is absent: + return skip_signature("signature_invalid") + if abs(current_timestamp() - sig_input.created) > freshness_window: + return skip_signature("signature_expired") // window: 300s RECOMMENDED // 3. Verify body digest (if body present) if "content-digest" in components: @@ -781,10 +846,13 @@ verify_rest_response(response, signer_profile_url): if response.headers["Content-Digest"] != expected: return skip_signature("digest_mismatch") - // 4. Reconstruct signature base + // 4. Reconstruct signature base. The ";req" components are taken from + // the request this verifier actually sent, NOT from anything the + // response asserts. Rebuilding from the response would be + // self-certifying and would not bind anything. signature_base = build_signature_base( components, response.status, - response.headers, keyid + response.headers, request, keyid ) // 5. Verify signature @@ -797,14 +865,20 @@ verify_rest_response(response, signer_profile_url): ### Replay Protection -UCP handles replay protection at the **business layer** through idempotency keys, -not at the signature layer. This provides separation of concerns: +UCP handles **request** 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 | +Idempotency keys are a request-direction mechanism and do not protect +responses. **Response** replay is handled at the signature layer instead, +by the request binding and `created` freshness window specified in +[REST Response Signing](#rest-response-signing). + **How it works:** 1. State-changing operations include an `idempotency-key` in the request @@ -847,9 +921,11 @@ modify the request payload — including retries with modified payment instruments, updated shipping addresses, swapped line items, or any other change to the request body. -**Note:** For **default UCP** signatures, the RFC 9421 `created` +**Note:** For **default UCP** *request* signatures, the RFC 9421 `created` parameter is **OPTIONAL** and replay protection is handled at the -business layer through idempotency keys, not signature timestamps. +business layer through idempotency keys, not signature timestamps. On +*response* signatures `created` is **REQUIRED** and enforced; see +[REST Response Signing](#rest-response-signing). **WBA-shape** signatures additionally carry `created`/`expires` (and SHOULD carry a `nonce`) for WBA verifiers; enforcing that freshness window is application-defined (RFC 9421 §3.2.1). See