|
| 1 | +# Copyright 2025 Google LLC |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# https://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | + |
| 15 | +"""Validation logic for the PaymentMandate cart-to-payment binding. |
| 16 | +
|
| 17 | +See the "Cart-to-Payment Mandate Binding" section of |
| 18 | +docs/ap2/specification.md for the normative requirements implemented here. |
| 19 | +""" |
| 20 | + |
| 21 | +import hashlib |
| 22 | +import logging |
| 23 | + |
| 24 | +from typing import Any |
| 25 | + |
| 26 | +import rfc8785 |
| 27 | + |
| 28 | +from ap2.models.mandate import PaymentMandate |
| 29 | + |
| 30 | + |
| 31 | +def validate_payment_mandate_signature(payment_mandate: PaymentMandate) -> None: |
| 32 | + """Validates that a PaymentMandate carries a user_authorization field. |
| 33 | +
|
| 34 | + Note: This is a placeholder - a production implementation must verify the |
| 35 | + cryptographic signature (e.g., sd-jwt-vc key-binding) embedded in |
| 36 | + user_authorization. Use validate_cart_mandate_hash() to enforce the |
| 37 | + cart-to-payment binding before releasing credentials or initiating payment. |
| 38 | +
|
| 39 | + Args: |
| 40 | + payment_mandate: The PaymentMandate to be validated. |
| 41 | +
|
| 42 | + Raises: |
| 43 | + ValueError: If the PaymentMandate has no user_authorization. |
| 44 | + """ |
| 45 | + # In a real implementation, full validation logic would reside here. For |
| 46 | + # demonstration purposes, we simply log that the authorization field is |
| 47 | + # populated. |
| 48 | + if payment_mandate.user_authorization is None: |
| 49 | + raise ValueError("User authorization not found in PaymentMandate.") |
| 50 | + |
| 51 | + logging.info("Valid PaymentMandate found.") |
| 52 | + |
| 53 | + |
| 54 | +def compute_cart_mandate_hash(cart_mandate_data: dict[str, Any]) -> str: |
| 55 | + """Computes the binding hash of a CartMandate JSON object. |
| 56 | +
|
| 57 | + The hash is hex(sha256(JCS(cart_mandate_data))), where JCS is the JSON |
| 58 | + Canonicalization Scheme defined in RFC 8785. |
| 59 | +
|
| 60 | + The input MUST be the CartMandate JSON object exactly as transmitted on |
| 61 | + the wire, not a re-serialized data model. Parsing into a schema model |
| 62 | + silently drops unknown or extension fields and can collapse an explicit |
| 63 | + null with an absent field, so a hash over a re-serialized model would not |
| 64 | + cover the full received object. JCS removes whitespace, key-order, and |
| 65 | + number-formatting variation, so hashing the raw object is stable across |
| 66 | + language implementations. |
| 67 | +
|
| 68 | + Args: |
| 69 | + cart_mandate_data: The CartMandate as a raw JSON object (parsed dict), |
| 70 | + exactly as sent or received. |
| 71 | +
|
| 72 | + Returns: |
| 73 | + The lowercase hex SHA-256 digest of the JCS canonical form. |
| 74 | + """ |
| 75 | + canonical_bytes = rfc8785.dumps(cart_mandate_data) |
| 76 | + return hashlib.sha256(canonical_bytes).hexdigest() |
| 77 | + |
| 78 | + |
| 79 | +def validate_cart_mandate_hash( |
| 80 | + payment_mandate: PaymentMandate, |
| 81 | + cart_mandate_data: dict[str, Any], |
| 82 | + *, |
| 83 | + allow_unbound_cart: bool = False, |
| 84 | +) -> None: |
| 85 | + """Verifies the cart-to-payment binding by recomputing the JCS hash. |
| 86 | +
|
| 87 | + Recomputes hex(sha256(JCS(cart_mandate_data))) over the raw received |
| 88 | + CartMandate JSON object and compares it against |
| 89 | + PaymentMandateContents.cart_mandate_hash per the "Cart-to-Payment Mandate |
| 90 | + Binding" section of the AP2 specification. |
| 91 | +
|
| 92 | + Verifiers MUST call this gate before releasing credentials or initiating |
| 93 | + payment; a mismatch MUST cause the transaction to be rejected. |
| 94 | +
|
| 95 | + The binding is enforced by default. A PaymentMandate without |
| 96 | + cart_mandate_hash is rejected unless allow_unbound_cart is explicitly set |
| 97 | + to True, which restricts the exemption to a controlled legacy rollout of |
| 98 | + mandates created before the binding requirement existed. |
| 99 | +
|
| 100 | + Args: |
| 101 | + payment_mandate: The PaymentMandate whose contents hold the expected |
| 102 | + hash. |
| 103 | + cart_mandate_data: The merchant-signed CartMandate as the raw JSON |
| 104 | + object received on the wire (for example the value returned by |
| 105 | + message_utils.find_data_part for CART_MANDATE_DATA_KEY), before any |
| 106 | + model parsing. |
| 107 | + allow_unbound_cart: If True, a missing cart_mandate_hash logs a warning |
| 108 | + and skips the check instead of rejecting. Defaults to False. |
| 109 | +
|
| 110 | + Raises: |
| 111 | + ValueError: If cart_mandate_hash is absent while allow_unbound_cart is |
| 112 | + False, or if it does not match the recomputed digest. |
| 113 | + """ |
| 114 | + expected = payment_mandate.payment_mandate_contents.cart_mandate_hash |
| 115 | + if expected is None: |
| 116 | + if allow_unbound_cart: |
| 117 | + logging.warning( |
| 118 | + "cart_mandate_hash absent from PaymentMandateContents and " |
| 119 | + "allow_unbound_cart is True - skipping binding check for a legacy " |
| 120 | + "mandate. Populate cart_mandate_hash to enforce strong binding." |
| 121 | + ) |
| 122 | + return |
| 123 | + raise ValueError( |
| 124 | + "cart_mandate_hash absent from PaymentMandateContents. The " |
| 125 | + "cart-to-payment binding is mandatory: reject this mandate, or opt " |
| 126 | + "out explicitly with allow_unbound_cart=True for legacy mandates " |
| 127 | + "only." |
| 128 | + ) |
| 129 | + |
| 130 | + actual = compute_cart_mandate_hash(cart_mandate_data) |
| 131 | + if expected != actual: |
| 132 | + raise ValueError( |
| 133 | + f"CartMandate hash mismatch: mandate carries {expected!r} but " |
| 134 | + f"recomputed {actual!r}. PaymentMandate does not match the " |
| 135 | + "merchant-authorized CartMandate." |
| 136 | + ) |
| 137 | + |
| 138 | + logging.info( |
| 139 | + "CartMandate hash verified: PaymentMandate is bound to cart %s.", |
| 140 | + payment_mandate.payment_mandate_contents.cart_mandate_id, |
| 141 | + ) |
0 commit comments