Skip to content
Open
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
7 changes: 5 additions & 2 deletions .cspell/custom-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ Crossmint
cryptographical
CYGPATTERN
Dafiti
disclosable
Disclosable
davecgh
dcql
Dcql
DCQL
deviceauth
Dfile
disclosable
Disclosable
dmypy
Doku
Dorg
Expand All @@ -47,6 +47,7 @@ emvco
endlocal
envoyproxy
esac
fastmcp
felixge
Fiuu
fontawesome
Expand Down Expand Up @@ -115,6 +116,7 @@ Nuvei
objx
octicons
okhttp
omitempty
opentelemetry
otelgrpc
otelhttp
Expand Down Expand Up @@ -142,6 +144,7 @@ renamesourcefileattribute
representment
repudiable
Revolut
rfc8785
Riskified
ROOTDIRS
ROOTDIRSRAW
Expand Down
1 change: 1 addition & 0 deletions .github/linters/.markdownlint.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"MD007": {
"indent": 4
},
"MD030": false,
"MD033": false,
"MD046": false,
"MD024": false
Expand Down
13 changes: 10 additions & 3 deletions .github/workflows/linter.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,34 @@ on:
pull_request:
branches: [main]

permissions:
contents: read
statuses: write
pull-requests: write

jobs:
build:
name: Lint Code Base
runs-on: ubuntu-latest

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
Expand Down
5 changes: 5 additions & 0 deletions biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"files": {
"includes": ["**", "!code/web-client"]
}
}
3 changes: 2 additions & 1 deletion code/samples/python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
141 changes: 141 additions & 0 deletions code/samples/python/src/common/validation.py
Original file line number Diff line number Diff line change
@@ -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,
)
8 changes: 8 additions & 0 deletions code/samples/python/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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'))
Loading
Loading