diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index ce73c361..2d3f1ebe 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -29,14 +29,14 @@ Crossmint cryptographical CYGPATTERN Dafiti -disclosable -Disclosable davecgh dcql Dcql DCQL deviceauth Dfile +disclosable +Disclosable dmypy Doku Dorg @@ -47,6 +47,7 @@ emvco endlocal envoyproxy esac +fastmcp felixge Fiuu fontawesome @@ -115,6 +116,7 @@ Nuvei objx octicons okhttp +omitempty opentelemetry otelgrpc otelhttp @@ -142,6 +144,7 @@ renamesourcefileattribute representment repudiable Revolut +rfc8785 Riskified ROOTDIRS ROOTDIRSRAW diff --git a/.github/linters/.markdownlint.json b/.github/linters/.markdownlint.json index 38fb124f..7794e952 100644 --- a/.github/linters/.markdownlint.json +++ b/.github/linters/.markdownlint.json @@ -4,6 +4,7 @@ "MD007": { "indent": 4 }, + "MD030": false, "MD033": false, "MD046": false, "MD024": false diff --git a/.github/workflows/linter.yaml b/.github/workflows/linter.yaml index fea1b9c1..cfeddead 100644 --- a/.github/workflows/linter.yaml +++ b/.github/workflows/linter.yaml @@ -4,6 +4,11 @@ on: pull_request: branches: [main] +permissions: + contents: read + statuses: write + pull-requests: write + jobs: build: name: Lint Code Base @@ -11,20 +16,22 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: fetch-depth: 0 + persist-credentials: false - name: Lint Code Base - uses: super-linter/super-linter/slim@v8 + uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0 env: DEFAULT_BRANCH: main GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} LOG_LEVEL: WARN SHELLCHECK_OPTS: -e SC1091 -e 2086 VALIDATE_ALL_CODEBASE: false - FILTER_REGEX_EXCLUDE: "^(\\.github/|\\.vscode/|code/samples/).*|CODE_OF_CONDUCT.md|CHANGELOG.md" + FILTER_REGEX_EXCLUDE: "^(\\.github/|\\.vscode/|code/samples/|code/web-client/).*|CODE_OF_CONDUCT.md|CHANGELOG.md" VALIDATE_BIOME_FORMAT: false + VALIDATE_BIOME_LINT: false VALIDATE_PYTHON_BLACK: false VALIDATE_PYTHON_FLAKE8: false VALIDATE_PYTHON_ISORT: false diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..b867da0b --- /dev/null +++ b/biome.json @@ -0,0 +1,5 @@ +{ + "files": { + "includes": ["**", "!code/web-client"] + } +} diff --git a/code/samples/python/pyproject.toml b/code/samples/python/pyproject.toml index 9c5693e7..5c5f4495 100644 --- a/code/samples/python/pyproject.toml +++ b/code/samples/python/pyproject.toml @@ -22,7 +22,8 @@ dependencies = [ "python-dotenv==1.2.2", "fastmcp==3.1.0", "cryptography==46.0.5", - "web3==7.15.0" + "web3==7.15.0", + "rfc8785>=0.1.2", ] keywords = ["payments", "a2a", "ap2"] readme = "README.md" diff --git a/code/samples/python/src/common/validation.py b/code/samples/python/src/common/validation.py new file mode 100644 index 00000000..d18e3e54 --- /dev/null +++ b/code/samples/python/src/common/validation.py @@ -0,0 +1,141 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Validation logic for the PaymentMandate cart-to-payment binding. + +See the "Cart-to-Payment Mandate Binding" section of +docs/ap2/specification.md for the normative requirements implemented here. +""" + +import hashlib +import logging + +from typing import Any + +import rfc8785 + +from ap2.models.mandate import PaymentMandate + + +def validate_payment_mandate_signature(payment_mandate: PaymentMandate) -> None: + """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 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 + # populated. + if payment_mandate.user_authorization is None: + raise ValueError("User authorization not found in PaymentMandate.") + + logging.info("Valid PaymentMandate found.") + + +def compute_cart_mandate_hash(cart_mandate_data: dict[str, Any]) -> str: + """Computes the binding hash of a CartMandate JSON object. + + The hash is hex(sha256(JCS(cart_mandate_data))), where JCS is the JSON + Canonicalization Scheme defined in RFC 8785. + + The input MUST be the CartMandate JSON object exactly as transmitted on + the wire, not a re-serialized data model. Parsing into a schema model + silently drops unknown or extension fields and can collapse an explicit + null with an absent field, so a hash over a re-serialized model would not + cover the full received object. JCS removes whitespace, key-order, and + number-formatting variation, so hashing the raw object is stable across + language implementations. + + Args: + cart_mandate_data: The CartMandate as a raw JSON object (parsed dict), + exactly as sent or received. + + Returns: + The lowercase hex SHA-256 digest of the JCS canonical form. + """ + canonical_bytes = rfc8785.dumps(cart_mandate_data) + return hashlib.sha256(canonical_bytes).hexdigest() + + +def validate_cart_mandate_hash( + payment_mandate: PaymentMandate, + cart_mandate_data: dict[str, Any], + *, + allow_unbound_cart: bool = False, +) -> None: + """Verifies the cart-to-payment binding by recomputing the JCS hash. + + Recomputes hex(sha256(JCS(cart_mandate_data))) over the raw received + CartMandate JSON object and compares it against + PaymentMandateContents.cart_mandate_hash per the "Cart-to-Payment Mandate + Binding" section of the AP2 specification. + + Verifiers MUST call this gate before releasing credentials or initiating + payment; a mismatch MUST cause the transaction to be rejected. + + The binding is enforced by default. A PaymentMandate without + cart_mandate_hash is rejected unless allow_unbound_cart is explicitly set + to True, which restricts the exemption to a controlled legacy rollout of + mandates created before the binding requirement existed. + + Args: + payment_mandate: The PaymentMandate whose contents hold the expected + hash. + cart_mandate_data: The merchant-signed CartMandate as the raw JSON + object received on the wire (for example the value returned by + message_utils.find_data_part for CART_MANDATE_DATA_KEY), before any + model parsing. + allow_unbound_cart: If True, a missing cart_mandate_hash logs a warning + and skips the check instead of rejecting. Defaults to False. + + Raises: + ValueError: If cart_mandate_hash is absent while allow_unbound_cart is + False, or if it does not match the recomputed digest. + """ + expected = payment_mandate.payment_mandate_contents.cart_mandate_hash + if expected is None: + if allow_unbound_cart: + logging.warning( + "cart_mandate_hash absent from PaymentMandateContents and " + "allow_unbound_cart is True - skipping binding check for a legacy " + "mandate. Populate cart_mandate_hash to enforce strong binding." + ) + return + raise ValueError( + "cart_mandate_hash absent from PaymentMandateContents. The " + "cart-to-payment binding is mandatory: reject this mandate, or opt " + "out explicitly with allow_unbound_cart=True for legacy mandates " + "only." + ) + + actual = compute_cart_mandate_hash(cart_mandate_data) + if expected != actual: + raise ValueError( + f"CartMandate hash mismatch: mandate carries {expected!r} but " + f"recomputed {actual!r}. PaymentMandate does not match the " + "merchant-authorized CartMandate." + ) + + logging.info( + "CartMandate hash verified: PaymentMandate is bound to cart %s.", + payment_mandate.payment_mandate_contents.cart_mandate_id, + ) diff --git a/code/samples/python/tests/conftest.py b/code/samples/python/tests/conftest.py new file mode 100644 index 00000000..12a86bed --- /dev/null +++ b/code/samples/python/tests/conftest.py @@ -0,0 +1,8 @@ +"""Makes the samples src/ tree importable without installing ap2-samples.""" + +import sys + +from pathlib import Path + + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'src')) diff --git a/code/samples/python/tests/validation_tests.py b/code/samples/python/tests/validation_tests.py new file mode 100644 index 00000000..8856d4b7 --- /dev/null +++ b/code/samples/python/tests/validation_tests.py @@ -0,0 +1,192 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the cart-to-payment mandate binding validation helpers.""" + +import copy + +import pytest + + +pytest.importorskip('rfc8785', reason='samples dependency rfc8785 not installed') + +from ap2.models.mandate import ( + CartContents, + CartMandate, + PaymentMandate, + PaymentMandateContents, +) +from ap2.models.payment_request import ( + PaymentCurrencyAmount, + PaymentDetailsInit, + PaymentItem, + PaymentMethodData, + PaymentRequest, +) +from common.validation import ( + compute_cart_mandate_hash, + validate_cart_mandate_hash, +) + + +def _payment_item(label: str = 'Total', value: float = 120.0) -> PaymentItem: + return PaymentItem( + label=label, + amount=PaymentCurrencyAmount(currency='USD', value=value), + ) + + +def _cart_mandate_data(include_authorization: bool = True) -> dict: + """Builds a CartMandate wire object as a merchant would transmit it.""" + cart_mandate = CartMandate( + contents=CartContents( + id='cart_001', + user_cart_confirmation_required=True, + payment_request=PaymentRequest( + method_data=[PaymentMethodData(supported_methods='CARD')], + details=PaymentDetailsInit( + id='order_001', + display_items=[_payment_item('High top shoes')], + total=_payment_item(), + ), + ), + cart_expiry='2027-01-01T00:00:00Z', + merchant_name='Example Merchant', + ), + merchant_authorization='hdr.payload.sig' if include_authorization + else None, + ) + return cart_mandate.model_dump(mode='json', exclude_none=True) + + +def _payment_mandate(cart_mandate_hash: str | None) -> PaymentMandate: + return PaymentMandate( + payment_mandate_contents=PaymentMandateContents( + payment_mandate_id='pm_001', + payment_details_id='order_001', + payment_details_total=_payment_item(), + payment_response={ + 'request_id': 'order_001', + 'method_name': 'CARD', + }, + merchant_agent='merchant_agent_001', + timestamp='2026-08-04T00:00:00+00:00', + cart_mandate_id='cart_001', + cart_mandate_hash=cart_mandate_hash, + ), + ) + + +def test_valid_binding_passes(): + """A hash over the raw wire object verifies cleanly, extensions included.""" + cart_data = _cart_mandate_data() + # Extension data outside the CartMandate model schema is part of the + # transmitted object and must be covered by the hash. + cart_data['x_merchant_extension'] = {'loyalty_tier': 'gold'} + payment_mandate = _payment_mandate(compute_cart_mandate_hash(cart_data)) + + validate_cart_mandate_hash(payment_mandate, cart_data) + + +def test_tampered_model_field_rejected(): + """Tampering with a schema field after hashing is detected.""" + cart_data = _cart_mandate_data() + payment_mandate = _payment_mandate(compute_cart_mandate_hash(cart_data)) + + tampered = copy.deepcopy(cart_data) + tampered['contents']['payment_request']['details']['total']['amount'][ + 'value' + ] = 1.0 + + with pytest.raises(ValueError, match='hash mismatch'): + validate_cart_mandate_hash(payment_mandate, tampered) + + +def test_tampered_extension_field_rejected(): + """Tampering outside the model schema is detected by the raw-object hash. + + A hash over a re-parsed Pydantic model would miss this: unknown fields are + silently dropped by model_validate, so the re-serialized form is identical + before and after tampering. + """ + cart_data = _cart_mandate_data() + cart_data['x_merchant_extension'] = {'loyalty_tier': 'gold'} + payment_mandate = _payment_mandate(compute_cart_mandate_hash(cart_data)) + + tampered = copy.deepcopy(cart_data) + tampered['x_merchant_extension'] = {'loyalty_tier': 'none'} + + with pytest.raises(ValueError, match='hash mismatch'): + validate_cart_mandate_hash(payment_mandate, tampered) + + +def test_injected_unknown_field_rejected(): + """Injecting a brand new unknown field after hashing is detected.""" + cart_data = _cart_mandate_data() + payment_mandate = _payment_mandate(compute_cart_mandate_hash(cart_data)) + + tampered = copy.deepcopy(cart_data) + tampered['x_injected'] = 'attacker-data' + + with pytest.raises(ValueError, match='hash mismatch'): + validate_cart_mandate_hash(payment_mandate, tampered) + + +def test_absent_hash_rejected_by_default(): + """A PaymentMandate without cart_mandate_hash is rejected by default.""" + cart_data = _cart_mandate_data() + payment_mandate = _payment_mandate(None) + + with pytest.raises(ValueError, match='cart_mandate_hash absent'): + validate_cart_mandate_hash(payment_mandate, cart_data) + + +def test_absent_hash_skipped_with_explicit_opt_out(): + """The legacy opt-out must be explicit and skips only the absent case.""" + cart_data = _cart_mandate_data() + payment_mandate = _payment_mandate(None) + + validate_cart_mandate_hash( + payment_mandate, cart_data, allow_unbound_cart=True + ) + + +def test_opt_out_does_not_weaken_present_hash(): + """allow_unbound_cart never bypasses verification of a present hash.""" + cart_data = _cart_mandate_data() + payment_mandate = _payment_mandate(compute_cart_mandate_hash(cart_data)) + + tampered = copy.deepcopy(cart_data) + tampered['contents']['merchant_name'] = 'Evil Merchant' + + with pytest.raises(ValueError, match='hash mismatch'): + validate_cart_mandate_hash( + payment_mandate, tampered, allow_unbound_cart=True + ) + + +def test_explicit_null_differs_from_absent_field(): + """An explicit null is not the same wire object as an absent field.""" + cart_data = _cart_mandate_data(include_authorization=False) + assert 'merchant_authorization' not in cart_data + payment_mandate = _payment_mandate(compute_cart_mandate_hash(cart_data)) + + with_null = copy.deepcopy(cart_data) + with_null['merchant_authorization'] = None + + assert compute_cart_mandate_hash(with_null) != compute_cart_mandate_hash( + cart_data + ) + with pytest.raises(ValueError, match='hash mismatch'): + validate_cart_mandate_hash(payment_mandate, with_null) diff --git a/code/sdk/python/ap2/models/mandate.py b/code/sdk/python/ap2/models/mandate.py index e3cead02..2d13f37c 100644 --- a/code/sdk/python/ap2/models/mandate.py +++ b/code/sdk/python/ap2/models/mandate.py @@ -162,6 +162,27 @@ class PaymentMandateContents(BaseModel): ), default_factory=lambda: datetime.now(UTC).isoformat(), ) + cart_mandate_id: str | None = Field( + None, + description=( + 'The unique identifier of the CartMandate bound to this payment. ' + 'SHOULD be populated on every new PaymentMandate.' + ), + ) + cart_mandate_hash: str | None = Field( + None, + description=( + 'hex(sha256(JCS(CartMandate))), where JCS is the RFC 8785 ' + 'canonical form of the CartMandate JSON object exactly as ' + 'transmitted. Verifiers MUST recompute this hash over the raw ' + 'received CartMandate JSON object, before any schema-based ' + 'parsing, and MUST reject the mandate if the value does not ' + 'match. Verifiers MUST reject a mandate that omits this field ' + 'unless an explicit legacy allowance is configured. See the ' + 'Cart-to-Payment Mandate Binding section of the AP2 ' + 'specification.' + ), + ) class PaymentMandate(BaseModel): diff --git a/docs/ap2/specification.md b/docs/ap2/specification.md index 80a6dd35..00475ab4 100644 --- a/docs/ap2/specification.md +++ b/docs/ap2/specification.md @@ -100,7 +100,7 @@ not. ## Mandates Mandates are the core means that AP2 uses to authorize agents. See -[Agent Authorization Framework][agent_authorization.md] for a description of +[Agent Authorization Framework](agent_authorization.md) for a description of how this works in the general case. AP2 defines two @@ -163,6 +163,38 @@ Credential Provider, and possibly Networks. For the full details of the Payment Mandate and Receipt structures, see [Payment Mandate](payment_mandate.md). +#### Cart-to-Payment Mandate Binding + +The `checkout_hash` binding above links the Payment Mandate to the Checkout +JWT and remains the primary Checkout binding. Deployments that additionally +exchange the cart as a JSON `CartMandate` object, as the reference SDK and +samples do, MUST also bind the `PaymentMandate` to that exact object, so that +a malicious or misconfigured agent cannot substitute a different cart after +the user has expressed intent. + +1. `PaymentMandateContents` MUST include a `cart_mandate_id` field + referencing the bound `CartMandate`, and a `cart_mandate_hash` field + containing `hex(sha256(JCS(CartMandate)))`, where JCS is the JSON + Canonicalization Scheme defined in RFC 8785, applied to the `CartMandate` + JSON object exactly as transmitted. + +2. Verifiers MUST compute the hash over the raw received `CartMandate` JSON + object, before any schema-based parsing. Hashing a re-serialized data + model is not sufficient: parsers drop unknown or extension fields and may + collapse an explicit `null` with an absent field, so tampering outside + the model schema would escape a model-derived hash. JCS canonicalization + removes whitespace, key-order, and number-formatting variation, so the + hash of the raw object is stable across language implementations. + Producers SHOULD omit optional fields that carry no value rather than + emitting `null`. + +3. Before releasing credentials or initiating payment, the Credential + Provider, Merchant, and Merchant Payment Processor each MUST recompute + the hash and compare it to `cart_mandate_hash`. A mismatch MUST cause the + transaction to be rejected. A `PaymentMandate` that omits + `cart_mandate_hash` MUST be rejected by default; a verifier MAY accept + one only through an explicit legacy configuration during rollout. + ## Modes There are two `modes` that AP2 can consider to operate in.