Skip to content

fix!: bind signed REST responses to their request - #699

Open
sakinaroufid wants to merge 3 commits into
Universal-Commerce-Protocol:mainfrom
sakinaroufid:fix/response-signature-request-binding
Open

fix!: bind signed REST responses to their request#699
sakinaroufid wants to merge 3 commits into
Universal-Commerce-Protocol:mainfrom
sakinaroufid:fix/response-signature-request-binding

Conversation

@sakinaroufid

Copy link
Copy Markdown
Contributor

Description

Signed REST responses are not bound to the request that produced them.

Proposal: #698. This PR is the reference spec change for it, opened for review alongside the proposal rather than as a request to merge ahead of TC approval.

Problem

REST Response Signing covers three components: @status, content-digest, content-type. The verifier enforces only those. Nothing ties a response to the request's method, path, query or body, nothing ties it to the serving origin, and created is OPTIONAL and never read by the verifier, so there is no freshness bound either.

A correctly signed response therefore stays valid once detached from its request. The adversary that matters here is not a network attacker, since TLS covers that. It is something that terminates TLS and can relay bytes but cannot mint signatures, because the signing key lives at the origin and not at the edge: a CDN, WAF, API gateway or reverse proxy in front of the Business. That is the same adversary the spec's own Intermediary Warning already assumes.

Rollback / stale replay. A signed 200 captured earlier is replayed verbatim against a later request for the same resource:

  1. Platform polls GET /checkout-sessions/chk_123 and gets a signed 200 with totals of $40.00.
  2. Business reprices the session to $60.00.
  3. Platform re-polls. The intermediary replays the earlier byte-identical response.
  4. @status still matches, content-digest matches the stale body's own bytes, content-type matches, and the Business's key is live. Verification passes.

Checking the body does not catch this. The body is a real, correctly signed earlier state of the correct object, and since it was internally consistent when issued it also passes the Platform side totals check in checkout.md.

Scope of the damage: the Business stays authoritative and settles against its own state (checkout.md), so it charges $60.00 and this is not a route to theft. What breaks is that the Platform and buyer act on stale terms. That matters where a Platform completes autonomously, and it matters for AP2 mandates, whose whole point is non-repudiable authorization over data that turns out to be attacker-selected. The same trick applies to replaying a superseded "status": "completed" or an old payment authorization response.

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 in practice by self-describing bodies carrying id. It is the weaker of the two, and it gets closed for free by the same fix.

Solution

Use RFC 9421 §2.4 request-response binding. It is a parameter in an RFC the spec already depends on, so there is no new UCP construct here. Response signatures cover @authority, @method and @path from the associated request via ;req, plus @query and content-digest when the request carried them.

- sig1=("@status" "content-digest" "content-type")
+ sig1=("@status" "@authority";req "@method";req "@path";req
+       "content-digest";req "content-digest" "content-type");created=...

Design decisions:

  • Verifiers rebuild the ;req components from the request they actually sent, never from anything the response claims. Rebuilding from the response would be self-certifying and would bind nothing. This is stated normatively at the reconstruction step.
  • created becomes REQUIRED on responses, with an enforced window (300s RECOMMENDED, not MUST, so deployments with worse clock skew can widen it). Binding alone does not bound how long a captured response can be replayed against a repeated identical request, which is the polling case above. It stays OPTIONAL on requests, where idempotency keys already handle replay.
  • Request signing is untouched. Response direction only, so deployed Platform request signers keep working.
  • Replay Protection reworded. It read as though idempotency keys covered both directions. They are request only. That section now says so and points at the response side mechanism.

Before / After

Case Before After
Stale response replayed against a later identical request accepted rejected (created window)
Response to /checkout-sessions/A served for /checkout-sessions/B accepted rejected (@path;req)
Response relayed from a different origin accepted rejected (@authority;req)
Response signature with no created accepted rejected
Tampered response body rejected rejected

Trade-offs

  • Breaking for verifiers. A verifier on the new rule rejects any signer still on the old shape, so the two move together. Doing it now costs prose, because nothing depends on the current shape yet. Once signers ship it turns into a coordinated cutover across every independent signer and verifier. Transition is publisher controlled by UCP version in the same way as feat!: promote keys[] to canonical profile signing key field #566.
  • ;req support is uneven across RFC 9421 libraries. Signers on a library without it have to build the signature base by hand. That is the main adoption cost and the most likely thing to get pushback.
  • A 300s window still allows replay inside the window. Closing that needs response side nonce tracking, which is not worth it here. The window takes exposure from unbounded to minutes.
  • Verifiers now need the request at verification time. In practice a client already has the request it sent, so this is a call signature change rather than new state.
  • CI cannot catch regressions here. Nothing in this repo asserts on covered components, so this rides on review until the conformance suite covers it.

Scope

Docs only in this repo. Nothing under source/ encodes covered components (profile.json and ap2_mandate.json mention signatures only in key and JWK descriptions), and no Python in main.py, hooks.py or scripts/ parses Signature-Input. The spec text is the product, so the prose change is the protocol change.

Per CONTRIBUTING § Significant Changes this counts twice over, as a Protocol Change and as Backwards Incompatibility, so it needs the proposal in #698 and TC approval before merge.

Category (Required)

Please select one or more categories that apply to this change.

  • Core Protocol: Changes to the base communication layer, global context, or breaking refactors. (Requires Technical Council approval)
  • Governance/Contributing: Updates to GOVERNANCE.md, CONTRIBUTING.md, or CODEOWNERS. (Requires Governance Council approval)
  • Capability: New schemas (Discovery, Cart, etc.) or extensions. (Requires Maintainer approval)
  • Documentation: Updates to README, or documentations regarding schema or capabilities. (Requires Maintainer approval)
  • Infrastructure: CI/CD, Linters, or build scripts. (Requires DevOps Maintainer approval)
  • Maintenance: Version bumps, lockfile updates, or minor bug fixes. (Requires DevOps Maintainer approval)
  • SDK: Language-specific SDK updates and releases. (Requires DevOps Maintainer approval)
  • Samples / Conformance: Maintaining samples and the conformance suite. (Requires Maintainer approval)
  • UCP Schema: Changes to the ucp-schema tool (resolver, linter, validator). (Requires Maintainer approval)
  • Community Health (.github): Updates to templates, workflows, or org-level configs. (Requires DevOps Maintainer approval)

Related Issues

Proposal: #698

Checklist

  • I have followed the Contributing Guide (including Conventional Commits title requirements and ! for breaking changes).
  • I have updated the documentation (if applicable).
  • My changes pass all local linting and formatting checks.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • (For Core/Capability) I have included/updated the relevant JSON schemas.
  • I have regenerated Python Pydantic models by running generate_models.sh under python_sdk.

No schema or model changes: no JSON Schema encodes signature covered components, so there is nothing to update or regenerate. No tests added because nothing in this repo can assert on covered components. validate_examples.py validates fenced JSON bodies against schemas and never parses Signature-Input, so this belongs in the conformance suite. The Test Plan in #698 covers it: a signer emitting the ;req set, plus negative cases for a response replayed against a different request, one outside the created window, and a verifier that rebuilds ;req from the response instead of its own request. Verification: doc example corpus 292 passed / 0 failed / 48 skipped, markdownlint clean across docs/specification/*.md, cspell clean on the three changed files.

Screenshots / Logs (if applicable)

No rendering change. Table rows, fenced text and http blocks, and prose.

validate_examples.py --schema-base source/schemas/
292 passed, 0 failed, 0 errors, 48 skipped

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.
@damaz91 damaz91 added status:needs-triage Signal that the PR is ready for human triage documentation Improvements or additions to documentation status:under-review gov:needs-tc-review and removed status:needs-triage Signal that the PR is ready for human triage labels Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation gov:needs-tc-review status:under-review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants