Skip to content

fix(rest/python): verify request signatures per RFC 9421 instead of accepting everything#122

Open
vishkaty wants to merge 7 commits into
Universal-Commerce-Protocol:mainfrom
vishkaty:feat/rfc9421-signatures
Open

fix(rest/python): verify request signatures per RFC 9421 instead of accepting everything#122
vishkaty wants to merge 7 commits into
Universal-Commerce-Protocol:mainfrom
vishkaty:feat/rfc9421-signatures

Conversation

@vishkaty

@vishkaty vishkaty commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #121.

What was wrong

verify_signature in rest/python/server/dependencies.py accepted every request, read a bespoke Request-Signature header that isn't part of the protocol, and its docstring described HMAC-SHA256 — while signatures.md mandates asymmetric RFC 9421 HTTP Message Signatures. A newcomer copying the reference server builds the wrong mechanism entirely (#121 has the full analysis).

What changed (4 commits, reviewable independently)

  1. ucp_signing.py + unit tests — a self-contained RFC 9421/9530 module (only new runtime dep: cryptography): Signature-Input/Signature parsing (RFC 8941 subset), signature-base construction, RFC 9530 Content-Digest over raw bytes, ES256 verification in fixed-width raw r||s (a DER-encoded signature is rejected — the encoding signatures.md explicitly forbids), Ed25519, JWK handling, and signer-key discovery from the UCP-Agent profile's signing_keys[] (with keys[] fallback per the in-flight canonicalization in ucp#566), including the HTTPS/no-redirect/special-use-IP guards the spec requires (with an explicit --allow_insecure_profile_urls carve-out for localhost demos, mirroring the conformance harness's own localhost profile server).
  2. The fixverify_signature (same name, same wiring) now actually verifies: parses the headers, enforces the spec's coverage gate (@method/@authority/@path, content-digest+content-type when a body is present, idempotency-key/ucp-agent when those headers are present), checks the digest, resolves the key, verifies the signature, and maps failures to the spec's error codes (401 signature_missing/signature_invalid/key_not_found; 400 digest_mismatch/algorithm_unsupported; 424 profile_unreachable; 422 profile_malformed). Enforcement is opt-in via a new --require_signatures flag (default off).
  3. The demo client now signs — ephemeral ES256 key, a small local profile server publishing its signing_keys[], and real key discovery end to end. --disable_signatures restores the old behavior.
  4. Docs — a "Request Signatures (RFC 9421)" section in the server README + client README flags.

Compatibility (designed to change nothing by default)

Consumer Effect
Official conformance suite (sends request-signature: "test", no RFC 9421 headers) No change: the legacy header is now optional-but-accepted, and no profile fetch or verification happens unless Signature-Input is present. A dedicated test replays the suite's exact header shape.
Existing integration tests Unchanged, all pass (5/5).
Happy-path demo Signs by default; permissive server default keeps it green either way.
Unsigned clients / curl examples Work exactly as before (default is verify-if-present, log-only).

Verification

  • 63 tests pass (35 unit + 23 integration + the 5 existing) on this branch rebased onto current main.
  • Oracles: RFC 9421 Appendix B vectors (the Ed25519 B.2.6 signature is byte-exact reproducible; ES256 B.2.4 verified), the RFC 9530 Content-Digest vector, and differential verification against http-message-signatures==2.0.1 (dev-only dependency, droppable on request) — both directions, ES256 and Ed25519.
  • Negative tests include: DER-encoded signature on the wire (rejected), tampered body (digest_mismatch), unknown kid, wrong key, uncovered required components, alg parameter present (the spec says the algorithm comes from the JWK, not the header), SSRF-guard cases, and profile-fetch failures.
  • Full pre-commit (--all-files) green; runs green in my fork: https://github.com/vishkaty/samples/actions/runs/28903273501
  • uv.lock diffs are mechanical (cryptography + the dev-only test oracle).

Scope and follow-ups

Deliberately not in this PR (happy to do as follow-ups in whichever order you prefer): signing outbound webhooks (the spec's "webhooks MUST be signed" — _notify_webhook currently sets no headers), response signing, and extracting the module toward the SDK.

Covered after a route sweep: the MCP transport endpoint (/mcp) now takes the same verify_signature dependency as every other inbound route — it was the one entry point accepting unsigned traffic even in enforced mode, and bridging tools/call later would have silently opened an unsigned path into the checkout handlers (verify-if-present by default, so unsigned MCP discovery is unaffected; both modes tested). Known scope boundary: the rest/nodejs sample has the same accept-everything gap; this PR is rest/python only, happy to mirror it there as a follow-up.

@damaz91 damaz91 added the status:needs-triage Signal that the PR is ready for human triage label Jul 8, 2026
@vishkaty
vishkaty force-pushed the feat/rfc9421-signatures branch from 7490f89 to f02d471 Compare July 13, 2026 16:04
katyalai and others added 6 commits July 15, 2026 12:20
Adds ucp_signing.py: a dependency-light (cryptography-only) implementation of
the UCP request-signature layer defined in signatures.md -- RFC 9421 signature
base construction, the UCP covered-component set, RFC 9530 Content-Digest over
raw body bytes, ES256 with fixed-width raw r||s (never ASN.1/DER, per the spec
MUST), Ed25519, and signer key discovery from the UCP-Agent profile.

Correctness is anchored to three independent oracles in ucp_signing_test.py:
RFC 9421 Appendix B (byte-exact Ed25519 B.2.6, verify-direction ES256 B.2.4)
and RFC 9530 digest vectors, an explicit DER-rejection test, and a differential
comparison against the independent http-message-signatures library (dev-only).

No behavior change yet -- this commit only adds the module and its tests.
…erything

The previous verify_signature accepted any request, short-circuited on the
literal string "test", read a bespoke Request-Signature header, and its
docstring described HMAC -- none of which matches signatures.md, which mandates
asymmetric RFC 9421 signatures (ES256 baseline) with key discovery from the
UCP-Agent profile.

verify_signature now performs real RFC 9421 verification via ucp_signing:
Content-Digest, the UCP covered-component coverage gate, raw-r||s ES256 /
Ed25519, and signer-key discovery from the UCP-Agent profile's signing_keys.

Compatibility is preserved by default:
- Two flags (config.py): --require_signatures (default false) and
  --allow_insecure_profile_urls (default false, for localhost demos/CI).
- With enforcement off, signatures are still verified when present and logged,
  but unsigned/invalid requests are allowed. No profile fetch occurs unless a
  Signature-Input header is present, so unsigned traffic (including the exact
  header shape the official conformance suite sends) is unaffected.
- The bespoke Request-Signature header is now optional but still accepted.

signature_integration_test.py proves both modes end to end against an
in-process localhost profile server: permissive passthrough (including an
official-conformance replay that asserts no key fetch happens) and, under
enforcement, the spec's error code for every failure mode.
The demo client now signs every request per RFC 9421 by default: it generates
an ephemeral ES256 key, publishes the public key from a small localhost profile
server, and points the UCP-Agent header at it so the merchant can discover the
key and verify the signature. Signing is applied centrally via an httpx auth
flow, so it covers the exact serialized body bytes.

--disable_signatures restores the previous unsigned behaviour (used to keep the
exported curl dialog replayable, since a real signature is bound to the body
and host).
Server README: a Request Signatures (RFC 9421) section covering what is
verified, the --require_signatures / --allow_insecure_profile_urls flags, the
spec error codes, and a local sign-then-verify demo recipe.
Client README: documents --disable_signatures.
…rifier

Keeps the PR at request verification; adds robustness fixes surfaced by an
adversarial self-review and drives the verifier to complete test coverage.

Verifier fixes (RFC 9421 exactness):
- @authority: strip the scheme default port (Section 2.2.3) so a signer that
  signs `host` still verifies behind an intermediary that presents `host:443`.
- @path: empty path normalises to `/` on the verify side too (Section 2.2.6).
- covered field values are OWS-trimmed (Section 2.1).
- coverage gate no longer requires `signature-agent`: it is a WBA-shape
  component this default-UCP verifier does not parse, so the promise now
  matches what it verifies (WBA-shape remains a documented follow-up).

Tests: ucp_signing.py to 100% line+branch coverage; positive and negative
request-verification cases (Ed25519 through verify_request, body without
Content-Digest, malformed digests/JWKs/headers, SSRF resolve paths, key
cache, no-profile wiring in both modes). 53 -> 91 tests, green on Python
3.10 and 3.12, ruff clean.
keys[] is the canonical RFC 7517 JWK Set field (ucp#566 removed
signing_keys[]), and verifiers resolve keyid only among signature-capable
keys.

- _extract_keys reads keys[] only; the removed signing_keys[] is not read,
  so the reference verifier models the merged spec exactly.
- verify_request filters to signature-capable keys before the kid match:
  skip use:"enc" and any key_ops that omits "verify" (RFC 7517 Sections
  4.2, 4.3), matching the merged verify pseudocode. Closes the two-verifier
  divergence noted on #566 (a use:"enc" key with the matching kid must not
  verify).
- the demo client and integration tests now publish keys[] (not the removed
  signing_keys[]), so the sample teaches the canonical shape.
- docstrings/README updated to keys[].

Validated against CURRENT upstream (samples main + python-sdk main): full
suite green on Python 3.10 and 3.12, ucp_signing.py at 100% line+branch
coverage, ruff clean, enforced-mode happy-path E2E completes.
@vishkaty
vishkaty force-pushed the feat/rfc9421-signatures branch from f02d471 to ec53ea8 Compare July 15, 2026 16:44
@vishkaty

Copy link
Copy Markdown
Contributor Author

Rebased onto current main following #131 — the branch now builds and tests against the pip-installed ucp-sdk (0.4.3): 106 integration + 74 unit tests green on the rebased tree. One robustness fix folded in: the signature test base suppressed inherited lifecycle tests via a hard-coded name list, so tests added to integration_test.py after this branch (e.g. #128's discount test) leaked into enforced mode unsigned; the suppression is now programmatic. Ready for review.

@vishkaty

Copy link
Copy Markdown
Contributor Author

Understand this is a large review — happy to split it into smaller, individually-mergeable PRs if that's easier to take in stages, e.g.:

  1. the RFC 9421 verifier module + its unit tests (self-contained, no server wiring)
  2. server integration behind the enforcement flag + integration tests
  3. the signing demo client

It's rebased onto current main and models merged #566 exactly (keys[]-only, sig_capable filtering). No urgency — just flagging the option, whichever is easiest for review.

Class sweep across every inbound route: /mcp was the one entry point
without the verify_signature dependency, so unsigned POSTs succeeded
even under --require_signatures. Discovery-only today (tools/call is
rejected), but bridging tools/call later would have silently opened an
unsigned transport into the checkout handlers.

Same policy as every other route: verify-if-present by default, so
unsigned MCP discovery keeps working in the default mode (covered by a
new permissive-mode test); required under --require_signatures (covered
by a new enforced-mode test mirroring the webhook one).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status:needs-triage Signal that the PR is ready for human triage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flower Shop verify_signature accepts every request and its docstring describes HMAC — spec requires RFC 9421 asymmetric signatures

3 participants