Skip to content

fix: implement JCS cart-to-payment mandate binding per RFC 8785 - #253

Open
chopmob-cloud wants to merge 8 commits into
google-agentic-commerce:mainfrom
chopmob-cloud:fix/jcs-cart-payment-binding-v3
Open

fix: implement JCS cart-to-payment mandate binding per RFC 8785#253
chopmob-cloud wants to merge 8 commits into
google-agentic-commerce:mainfrom
chopmob-cloud:fix/jcs-cart-payment-binding-v3

Conversation

@chopmob-cloud

@chopmob-cloud chopmob-cloud commented May 1, 2026

Copy link
Copy Markdown

Summary

Closes the CartMandate / PaymentMandate binding gap raised in #211.

The core problem: without a cryptographic link from CartMandate to PaymentMandate, 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.

Note: This replaces the previously closed PR #251. The branch on #251 was inadvertently broken during a local history-cleanup pass (the force-push lost the shared ancestor with upstream/main, so GitHub auto-closed the PR). The code below is identical to what #251 carried: three commits cherry-picked clean onto the current upstream/main tip. All Gemini high-priority feedback from the earlier closed PR #241 has been incorporated upfront: model_dump(exclude_none=True) for cross-language hash consistency, f-strings throughout, and import path updated from ap2.types.mandate to ap2.models.mandate.

Spec (docs/ap2/specification.md)

New normative section Cart-to-Payment Mandate Binding under SS Payment Mandate with three requirements:

  1. PaymentMandateContents MUST include cart_mandate_id and cart_mandate_hash = hex(sha256(JCS(CartMandate))).
  2. JCS serialisation MUST exclude null/None optional fields so Python and Go (omitempty) produce identical canonical bytes.
  3. Verifiers (CP, Merchant, MPP) 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 - hex(sha256(JCS(CartMandate))).

Both are Optional for 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 explicit null with an absent field, so tampering outside the model schema would escape a model-derived hash. The verifier is strict by default: a PaymentMandate that omits cart_mandate_hash is rejected. An explicit keyword-only allow_unbound_cart=True opt-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 by draft-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

  • Valid binding passes, with an extension field included in the hash.
  • A tampered model field is rejected (hash mismatch).
  • A tampered extension field is rejected (escapes a model-derived hash, caught by hashing raw bytes).
  • An injected unknown field is rejected.
  • An absent cart_mandate_hash is rejected by default.
  • An absent hash is skipped only with explicit allow_unbound_cart=True, and that opt-out does not weaken a present hash.
  • An explicit null differs 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

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.
@chopmob-cloud
chopmob-cloud requested a review from a team as a code owner May 1, 2026 13:47

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@chopmob-cloud

Copy link
Copy Markdown
Author

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.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

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 model_dump(exclude_none=True) approach effectively aligns with Go's omitempty behavior. The documentation updates and the validation helper in code/samples/python/src/common/validation.py are clear and correctly implement the requirements specified in the PR description. This looks solid.

@chopmob-cloud

Copy link
Copy Markdown
Author

The Biome lint failures are pre-existing in code/web-client/ -- none are in files this PR touches (code/sdk/python/, code/samples/python/, docs/). PR #249 resolves all 22 errors. Flagging for @GarethCOliver -- Gemini found no issues with the JCS approach, and the cart_mandate_hash convention has already been adopted normatively in AP2-COMPOSITION.md by PEAC Protocol. Happy to rebase once #249 is merged, or both can land together as they are non-conflicting.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant