Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions samples/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies = [
"google-genai",
"httpx",
"requests",
"rfc8785>=0.1.2",
"ap2"
]
keywords = ["payments", "a2a", "ap2"]
Expand Down
65 changes: 62 additions & 3 deletions samples/python/src/common/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")

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.

high

To ensure cross-language consistency with implementations that use omitempty (like the Go implementation in this repository), you should exclude None values when dumping the model to a JSON-compatible dictionary. Otherwise, Python will include optional fields as null, while Go will omit them entirely, leading to different JCS outputs and hash mismatches.

Suggested change
cart_dict = cart_mandate.model_dump(mode="json")
cart_dict = cart_mandate.model_dump(mode="json", exclude_none=True)

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)
)
Comment on lines +87 to +91

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.

low

Modern Python (3.6+) prefers f-strings for string formatting as they are more readable and efficient than the old-style % operator.

Suggested change
raise ValueError(
"CartMandate hash mismatch: mandate carries %r but recomputed %r. "
"PaymentMandate does not match the merchant-authorised CartMandate."
% (expected, actual)
)
if expected != actual:
raise ValueError(
f"CartMandate hash mismatch: mandate carries {expected!r} but recomputed {actual!r}. "
"PaymentMandate does not match the merchant-authorised CartMandate."
)
References
  1. Modern Python string formatting should prefer f-strings over % formatting. (link)


logging.info(
"CartMandate hash verified: PaymentMandate is bound to cart %s.",
payment_mandate.payment_mandate_contents.cart_mandate_id,
)
53 changes: 29 additions & 24 deletions samples/python/src/roles/shopping_agent/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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,
),
)

Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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")

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.

high

When generating a canonical hash intended for cross-language verification, it is critical to handle optional fields consistently. Using exclude_none=True ensures that fields with None values are omitted from the dictionary, matching the behavior of Go's omitempty tag used in the corresponding types.

Suggested change
cart_dict = cart_mandate.model_dump(mode="json")
cart_dict = cart_mandate.model_dump(mode="json", exclude_none=True)

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")

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.

high

Similar to the cart mandate hash, the payment mandate contents hash must be deterministic across languages. Ensure None values are excluded to align with implementations that omit empty optional fields.

Suggested change
contents_dict = payment_mandate_contents.model_dump(mode="json")
contents_dict = payment_mandate_contents.model_dump(mode="json", exclude_none=True)

canonical_bytes = rfc8785.dumps(contents_dict)
return hashlib.sha256(canonical_bytes).hexdigest()


def _parse_cart_mandates(artifacts: list[Artifact]) -> list[CartMandate]:
Expand Down
16 changes: 16 additions & 0 deletions src/ap2/types/mandate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down