-
Notifications
You must be signed in to change notification settings - Fork 476
fix: implement JCS cart-to-payment mandate binding per RFC 8785 (closes #211) #241
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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) | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
Comment on lines
+87
to
+91
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Modern Python (3.6+) prefers f-strings for string formatting as they are more readable and efficient than the old-style
Suggested change
References
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| logging.info( | ||||||||||||||||||||||
| "CartMandate hash verified: PaymentMandate is bound to cart %s.", | ||||||||||||||||||||||
| payment_mandate.payment_mandate_contents.cart_mandate_id, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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") | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When generating a canonical hash intended for cross-language verification, it is critical to handle optional fields consistently. Using
Suggested change
|
||||||
| 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") | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Similar to the cart mandate hash, the payment mandate contents hash must be deterministic across languages. Ensure
Suggested change
|
||||||
| canonical_bytes = rfc8785.dumps(contents_dict) | ||||||
| return hashlib.sha256(canonical_bytes).hexdigest() | ||||||
|
|
||||||
|
|
||||||
| def _parse_cart_mandates(artifacts: list[Artifact]) -> list[CartMandate]: | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To ensure cross-language consistency with implementations that use
omitempty(like the Go implementation in this repository), you should excludeNonevalues when dumping the model to a JSON-compatible dictionary. Otherwise, Python will include optional fields asnull, while Go will omit them entirely, leading to different JCS outputs and hash mismatches.