diff --git a/docs/specification.md b/docs/specification.md index 44d92b8e..23aa49e3 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -347,6 +347,48 @@ below information. as evidence during representment with the network/issuer as defined by network rules. +##### 4.1.3.1 Cart-to-Payment Mandate Binding + +A PaymentMandate is not a standalone credential — it is explicitly bound to a +specific, merchant-authorised CartMandate (or IntentMandate in the +human-not-present flow). This binding is what closes the consent integrity +chain: the goods the user approved, the cart the merchant signed, and the +payment that is ultimately executed must all refer to the same transaction. + +**Normative requirements for Human-Present transactions:** + +1. **Explicit cart reference.** + `PaymentMandateContents` MUST include `cart_mandate_id` (set to + `CartMandate.contents.id`) and `cart_mandate_hash` so that the bound cart + can be identified and re-verified by any downstream party without out-of-band + communication. + +2. **Canonical hash algorithm.** + `cart_mandate_hash` MUST be computed as: + + ``` + cart_mandate_hash = hex(sha256(JCS(CartMandate))) + ``` + + where JCS denotes the JSON Canonicalization Scheme defined in + [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785). JCS produces a + deterministic byte sequence regardless of the serialisation language or + floating-point formatting (e.g., `120.0` in Python, `120` in Go, and `120` + in TypeScript all canonicalise identically), ensuring that verifiers built + in different languages produce the same digest for logically equivalent carts. + +3. **Verifier obligations.** + Before releasing credentials or initiating payment, the Credential Provider, + Merchant, and Merchant Payment Processor MUST each: + - Retrieve the CartMandate referenced by `cart_mandate_id`. + - Recompute `hex(sha256(JCS(CartMandate)))`. + - Compare the recomputed digest to `cart_mandate_hash`. + - **MUST reject** the transaction if the values do not match. + + A mismatch indicates that the PaymentMandate was constructed against a + different cart than the one presented to and approved by the user, and the + transaction MUST NOT proceed. + This architecture represents a significant evolution from traditional, imperative API calls (e.g., `create_order`) to a model of "contractual conversation." The protocol messages are not simply commands; they are steps in diff --git a/samples/python/pyproject.toml b/samples/python/pyproject.toml index 54f67bed..a104f372 100644 --- a/samples/python/pyproject.toml +++ b/samples/python/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "google-genai", "httpx", "requests", + "rfc8785>=0.1.2", "ap2" ] keywords = ["payments", "a2a", "ap2"] diff --git a/samples/python/src/common/validation.py b/samples/python/src/common/validation.py index d7e2dce3..e96ace4a 100644 --- a/samples/python/src/common/validation.py +++ b/samples/python/src/common/validation.py @@ -12,21 +12,30 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Validation logic for PaymentMandate.""" +"""Validation logic for PaymentMandate cart-to-payment binding (AP2 s.4.1.3).""" +import hashlib import logging +import rfc8785 + +from ap2.types.mandate import CartMandate from ap2.types.mandate import PaymentMandate def validate_payment_mandate_signature(payment_mandate: PaymentMandate) -> None: - """Validates the PaymentMandate signature. + """Validates that a PaymentMandate carries a user_authorization field. + + Note: This is a placeholder - a production implementation must verify the + cryptographic signature (e.g., sd-jwt-vc key-binding) embedded in + user_authorization. Use validate_cart_mandate_hash() to enforce the + cart-to-payment binding before releasing credentials or initiating payment. Args: payment_mandate: The PaymentMandate to be validated. Raises: - ValueError: If the PaymentMandate signature is not valid. + ValueError: If the PaymentMandate has no user_authorization. """ # In a real implementation, full validation logic would reside here. For # demonstration purposes, we simply log that the authorization field is @@ -35,3 +44,53 @@ def validate_payment_mandate_signature(payment_mandate: PaymentMandate) -> None: raise ValueError("User authorization not found in PaymentMandate.") logging.info("Valid PaymentMandate found.") + + +def validate_cart_mandate_hash( + payment_mandate: PaymentMandate, + cart_mandate: CartMandate, +) -> None: + """Verifies the cart-to-payment binding by recomputing the JCS hash. + + Recomputes sha256(RFC_8785(CartMandate)) and compares it against + PaymentMandateContents.cart_mandate_hash per AP2 section 4.1.3. + + Verifiers MUST call this gate before releasing credentials or initiating + payment; a mismatch MUST cause the transaction to be rejected. + + If cart_mandate_hash is absent (mandate predates this field) a warning is + logged and the check is skipped so that older implementations remain + compatible during rollout. + + Args: + payment_mandate: The PaymentMandate whose contents hold the expected hash. + cart_mandate: The merchant-signed CartMandate to verify against. + + Raises: + ValueError: If cart_mandate_hash is present but does not match the + recomputed digest. + """ + expected = payment_mandate.payment_mandate_contents.cart_mandate_hash + if expected is None: + logging.warning( + "cart_mandate_hash absent from PaymentMandateContents - " + "skipping binding check (mandate predates AP2 section 4.1.3 JCS " + "requirement). Populate cart_mandate_hash to enforce strong binding." + ) + return + + cart_dict = cart_mandate.model_dump(mode="json") + canonical_bytes = rfc8785.dumps(cart_dict) + actual = hashlib.sha256(canonical_bytes).hexdigest() + + if expected != actual: + raise ValueError( + "CartMandate hash mismatch: mandate carries %r but recomputed %r. " + "PaymentMandate does not match the merchant-authorised CartMandate." + % (expected, actual) + ) + + logging.info( + "CartMandate hash verified: PaymentMandate is bound to cart %s.", + payment_mandate.payment_mandate_contents.cart_mandate_id, + ) diff --git a/samples/python/src/roles/shopping_agent/tools.py b/samples/python/src/roles/shopping_agent/tools.py index 1d8b7bf1..63997d40 100644 --- a/samples/python/src/roles/shopping_agent/tools.py +++ b/samples/python/src/roles/shopping_agent/tools.py @@ -20,9 +20,12 @@ from datetime import datetime from datetime import timezone +import hashlib import os import uuid +import rfc8785 + from a2a.types import Artifact from google.adk.tools.tool_context import ToolContext @@ -204,6 +207,7 @@ def create_payment_mandate( payer_email=user_email, ) + cart_mandate_hash = _generate_cart_mandate_hash(cart_mandate) payment_mandate = PaymentMandate( payment_mandate_contents=PaymentMandateContents( payment_mandate_id=uuid.uuid4().hex, @@ -212,6 +216,8 @@ def create_payment_mandate( payment_details_total=payment_request.details.total, payment_response=payment_response, merchant_agent=cart_mandate.contents.merchant_name, + cart_mandate_id=cart_mandate.contents.id, + cart_mandate_hash=cart_mandate_hash, ), ) @@ -240,7 +246,13 @@ def sign_mandates_on_user_device(tool_context: ToolContext) -> str: """ payment_mandate: PaymentMandate = tool_context.state["payment_mandate"] cart_mandate: CartMandate = tool_context.state["cart_mandate"] - cart_mandate_hash = _generate_cart_mandate_hash(cart_mandate) + # cart_mandate_hash is already embedded in payment_mandate_contents; + # re-read it here so the user_authorization signs the same bytes that + # create_payment_mandate committed to. + cart_mandate_hash = ( + payment_mandate.payment_mandate_contents.cart_mandate_hash + or _generate_cart_mandate_hash(cart_mandate) + ) payment_mandate_hash = _generate_payment_mandate_hash( payment_mandate.payment_mandate_contents ) @@ -283,46 +295,39 @@ async def send_signed_payment_mandate_to_credentials_provider( def _generate_cart_mandate_hash(cart_mandate: CartMandate) -> str: - """Generates a cryptographic hash of the CartMandate. + """Returns sha256(RFC 8785 canonical form of CartMandate). - This hash serves as a tamper-proof reference to the specific merchant-signed - cart offer that the user has approved. - - Note: This is a placeholder implementation for development. A real - implementation must use a secure hashing algorithm (e.g., SHA-256) on the - canonical representation of the CartMandate object. + Produces a deterministic, cross-language hash of the merchant-signed cart + by serialising the CartMandate via JSON Canonicalization Scheme (JCS, + RFC 8785) before hashing. This guarantees that Python float ``120.0``, + Go ``120``, and TypeScript ``120`` all yield the same digest for logically + identical carts. Args: - cart_mandate: The complete CartMandate object, including the merchant's - authorization. + cart_mandate: The complete CartMandate, including merchant_authorization. Returns: - A string representing the hash of the cart mandate. + Lowercase hex SHA-256 digest string. """ - return "fake_cart_mandate_hash_" + cart_mandate.contents.id + cart_dict = cart_mandate.model_dump(mode="json") + canonical_bytes = rfc8785.dumps(cart_dict) + return hashlib.sha256(canonical_bytes).hexdigest() def _generate_payment_mandate_hash( payment_mandate_contents: PaymentMandateContents, ) -> str: - """Generates a cryptographic hash of the PaymentMandateContents. - - This hash creates a tamper-proof reference to the specific payment details - the user is about to authorize. - - Note: This is a placeholder implementation for development. A real - implementation must use a secure hashing algorithm (e.g., SHA-256) on the - canonical representation of the PaymentMandateContents object. + """Returns sha256(RFC 8785 canonical form of PaymentMandateContents). Args: payment_mandate_contents: The payment mandate contents to hash. Returns: - A string representing the hash of the payment mandate contents. + Lowercase hex SHA-256 digest string. """ - return ( - "fake_payment_mandate_hash_" + payment_mandate_contents.payment_mandate_id - ) + contents_dict = payment_mandate_contents.model_dump(mode="json") + canonical_bytes = rfc8785.dumps(contents_dict) + return hashlib.sha256(canonical_bytes).hexdigest() def _parse_cart_mandates(artifacts: list[Artifact]) -> list[CartMandate]: diff --git a/src/ap2/types/mandate.py b/src/ap2/types/mandate.py index c5506689..3dcca455 100644 --- a/src/ap2/types/mandate.py +++ b/src/ap2/types/mandate.py @@ -154,6 +154,22 @@ class PaymentMandateContents(BaseModel): ), ) merchant_agent: str = Field(..., description="Identifier for the merchant.") + cart_mandate_id: Optional[str] = Field( + None, + description=( + "The unique identifier of the CartMandate this PaymentMandate is" + " bound to. MUST equal CartMandate.contents.id." + ), + ) + cart_mandate_hash: Optional[str] = Field( + None, + description=( + "A hex-encoded SHA-256 digest of the canonical CartMandate, computed" + " as sha256(RFC_8785(CartMandate)). Verifiers MUST recompute this" + " value and MUST reject the mandate if it does not match before" + " releasing credentials or initiating payment." + ), + ) timestamp: str = Field( description=( "The date and time the mandate was created, in ISO 8601 format."