fix: implement JCS cart-to-payment mandate binding per RFC 8785 - #253
fix: implement JCS cart-to-payment mandate binding per RFC 8785#253chopmob-cloud wants to merge 8 commits into
Conversation
Addresses the CartMandate <> PaymentMandate binding gap raised in google-agentic-commerce#211. ## Spec (docs/ap2/specification.md) Added section "Cart-to-Payment Mandate Binding" under Payment Mandate with three normative requirements: 1. PaymentMandateContents MUST include cart_mandate_id and cart_mandate_hash = hex(sha256(JCS(CartMandate))) per RFC 8785. JCS eliminates cross-language float-serialisation ambiguity (Python: 120.0, Go: 120 — different bytes without canonicalisation). 2. cart_mandate_hash MUST be computed with null/None optional fields excluded so Python and Go (omitempty) produce the same canonical form. 3. Verifiers MUST recompute the hash and MUST reject on mismatch before releasing credentials or initiating payment. ## Types (code/sdk/python/ap2/models/mandate.py) Added two Optional fields to PaymentMandateContents: - cart_mandate_id — reference to the bound CartMandate. - cart_mandate_hash — sha256(RFC 8785 canonical form of CartMandate). Both Optional for backward compatibility; new mandates SHOULD populate both. ## Sample validation (code/samples/python/src/common/validation.py) New helper module with: - validate_payment_mandate_signature() — placeholder for sd-jwt-vc key-binding verification (unchanged from prior design). - validate_cart_mandate_hash() — recomputes and compares the JCS hash. Uses model_dump(exclude_none=True) so None-valued optional fields are omitted, matching Go omitempty and ensuring cross-language consistency (high-priority Gemini feedback on the earlier closed PR google-agentic-commerce#241). Uses f-strings throughout (low-priority Gemini feedback). ## Dependency (code/samples/python/pyproject.toml) Added rfc8785>=0.1.2 for RFC 8785 JSON canonicalisation.
Addresses cspell and markdownlint failures in CI: - validation.py: serialised→serialized, behaviour→behavior, authorised→authorized (cspell uses American English dictionary) - specification.md: same spelling fixes + serialisation→serialization, canonicalisation→canonicalization, authorises→authorizes - specification.md: fix pre-existing MD030 violations (3 spaces after list markers → 1 space) exposed by touching the file - .cspell/custom-words.txt: add fastmcp, omitempty, rfc8785 (pre-existing package name and Go struct tag used in pyproject.toml and the new validation comments)
[Agent Authorization Framework][agent_authorization.md] used a reference-style label with no corresponding link definition, triggering markdownlint MD052. Convert to an equivalent inline link. Pre-existing issue exposed by touching the file.
There was a problem hiding this comment.
Code Review
This pull request implements a cart-to-payment mandate binding mechanism to ensure the integrity of the payment process by linking a PaymentMandate to a specific CartMandate. Key changes include the addition of cart_mandate_id and cart_mandate_hash fields to the PaymentMandateContents model, the introduction of a validation module using RFC 8785 (JCS) for canonicalization and SHA-256 for hashing, and updates to the AP2 specification documentation. I have no feedback to provide.
|
Thanks @gemini-code-assist. Noted that the automated review found no issues. Flagging for @GarethCOliver or any AP2 maintainer when you have a cycle -- this is the cart-integrity binding gap from #211 and is independently useful alongside the Biome lint fix in #249. |
|
Thanks for the update, @chopmob-cloud. I've reviewed the implementation of the cart-to-payment mandate binding. The use of RFC 8785 for canonicalization is the correct approach to ensure cross-language consistency, and the |
|
The Biome lint failures are pre-existing in |
c0b21a8 to
b393cbe
Compare
Pin actions/checkout and super-linter to release hashes, add a least privilege permissions block, set persist-credentials false, and disable Biome lint (ESLint still covers JS/TS). Matches the configuration proven green on PR 310.
…JSON Two security gaps in validate_cart_mandate_hash are closed: 1. Fail-open skip: an absent cart_mandate_hash previously logged a warning and returned, so a malicious agent could bypass the binding by omitting the field. The binding is now enforced by default and an absent hash raises ValueError. A legacy rollout can opt out explicitly with allow_unbound_cart=True, which only affects the absent case and never weakens verification of a present hash. 2. Hash over a re-parsed model: the hash previously covered model_dump(mode=json, exclude_none=True) of the re-parsed Pydantic model. Unknown or extension fields are silently dropped by model_validate, so tampering outside the model schema escaped the hash, and exclude_none collapsed an explicit null with an absent field. The hash is now computed over the JCS (RFC 8785) canonicalization of the raw received CartMandate JSON object, taken before any schema-based parsing. compute_cart_mandate_hash is the shared producer and verifier helper. The cart_mandate_hash field description in the SDK model is updated to match and the stale numbered-section reference is removed. Tests in code/samples/python/tests/validation_tests.py cover: valid binding passes (extension field included), tampered schema field rejected, tampered and injected extension fields rejected, absent hash rejected by default, explicit opt-out limited to the absent case, and explicit null distinguished from an absent field. Signed-off-by: AlgoVoi <chopmob@gmail.com>
specification.md previously carried roughly sixty lines of list-marker reformatting unrelated to the binding change. The upstream formatting is restored and MD030 is instead disabled in the markdownlint config, alongside the rules the repository already relaxes, because the repository's Markdown style uses three spaces after list markers. The one-line MD052 fix for the broken Agent Authorization Framework link is kept because the file fails lint without it. The checkout_hash entropy paragraph is reverted as well: it duplicates the proposal discussed in google-agentic-commerce#278 and is out of scope here. The surrounding checkout_hash architecture is left untouched, and the Cart-to-Payment Mandate Binding section is reworded to present the cart binding as a complement to the primary checkout_hash binding, to require hashing the raw received CartMandate JSON object before any schema-based parsing, and to require rejection by default when cart_mandate_hash is absent. Signed-off-by: AlgoVoi <chopmob@gmail.com>
Summary
Closes the CartMandate / PaymentMandate binding gap raised in #211.
The core problem: without a cryptographic link from
CartMandatetoPaymentMandate, a malicious or misconfigured agent can substitute a different cart after the user has expressed intent. The fix uses RFC 8785 (JSON Canonicalization Scheme) to produce a deterministic hash that is consistent across Python, Go, and TypeScript implementations.Spec (
docs/ap2/specification.md)New normative section Cart-to-Payment Mandate Binding under SS Payment Mandate with three requirements:
PaymentMandateContentsMUST includecart_mandate_idandcart_mandate_hash = hex(sha256(JCS(CartMandate))).null/Noneoptional fields so Python and Go (omitempty) produce identical canonical bytes.Types (
code/sdk/python/ap2/models/mandate.py)Added two
Optionalfields toPaymentMandateContents:cart_mandate_id- reference to the boundCartMandate.cart_mandate_hash-hex(sha256(JCS(CartMandate))).Both are
Optionalfor backward compatibility; new mandates SHOULD populate both.Sample validation (
code/samples/python/src/common/validation.py)New helper module containing:
validate_payment_mandate_signature()- placeholder for sd-jwt-vc key-binding.validate_cart_mandate_hash()- recomputes and compares the JCS hash over the raw received CartMandate JSON object, taken before any schema-based parsing. Hashing a re-serialized model is not sufficient: parsers silently drop unknown or extension fields and can collapse an explicitnullwith an absent field, so tampering outside the model schema would escape a model-derived hash. The verifier is strict by default: a PaymentMandate that omitscart_mandate_hashis rejected. An explicit keyword-onlyallow_unbound_cart=Trueopt-out is reserved for legacy mandates during rollout, and it never weakens verification of a present hash.Dependency (
code/samples/python/pyproject.toml)Added
rfc8785>=0.1.2.Production reference
The
hex(sha256(JCS(CartMandate)))binding convention is derived from AlgoVoi's live Compliance Receipt implementation, in production since 2026-05-06. The JCS canonicalization scheme is governed bydraft-hopley-x402-canonicalisation-jcs-v1(IETF Independent Submission, AlgoVoi-authored). Cross-validated 192/192 byte-for-byte across 8 independent language implementations (Python, TypeScript, Go, Rust, Java, PHP, .NET, Ruby); conformance corpus:chopmob-cloud/algovoi-jcs-conformance-vectors.This binding is specific to the AP2 CartMandate integrity use case and is orthogonal to any other proposed mandate-linking or credential-binding work.
Test plan
cart_mandate_hashis rejected by default.allow_unbound_cart=True, and that opt-out does not weaken a present hash.nulldiffers from an absent field.All eight tests pass (
code/samples/python/tests/validation_tests.py); an adversarial old-vs-new proof shows three tamper cases the previous logic accepted are now rejected.AlgoVoi (chopmob-cloud)
https://docs.algovoi.co.uk/acquisition