Skip to content

Commit d1cb27c

Browse files
committed
fix: bind CartMandate to PaymentMandate via RFC 8785 JCS hash (closes #211)
Adds cart_mandate_id and cart_mandate_hash = hex(sha256(JCS(CartMandate))) to PaymentMandateContents, and a strict verifier that recomputes the hash over the raw received CartMandate bytes and rejects on mismatch, absent hash, or tampered/injected fields. Fail-closed by default; an explicit allow_unbound_cart opt-out is reserved for legacy mandates and never weakens a present hash. Signed-off-by: AlgoVoi <chopmob@gmail.com>
1 parent e1ea56d commit d1cb27c

9 files changed

Lines changed: 408 additions & 4 deletions

File tree

.cspell/custom-words.txt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,14 @@ Crossmint
2929
cryptographical
3030
CYGPATTERN
3131
Dafiti
32-
disclosable
33-
Disclosable
3432
davecgh
3533
dcql
3634
Dcql
3735
DCQL
3836
deviceauth
3937
Dfile
38+
disclosable
39+
Disclosable
4040
dmypy
4141
Doku
4242
Dorg
@@ -47,6 +47,7 @@ emvco
4747
endlocal
4848
envoyproxy
4949
esac
50+
fastmcp
5051
felixge
5152
Fiuu
5253
fontawesome
@@ -115,6 +116,7 @@ Nuvei
115116
objx
116117
octicons
117118
okhttp
119+
omitempty
118120
opentelemetry
119121
otelgrpc
120122
otelhttp
@@ -142,6 +144,7 @@ renamesourcefileattribute
142144
representment
143145
repudiable
144146
Revolut
147+
rfc8785
145148
Riskified
146149
ROOTDIRS
147150
ROOTDIRSRAW

.github/linters/.markdownlint.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"MD007": {
55
"indent": 4
66
},
7+
"MD030": false,
78
"MD033": false,
89
"MD046": false,
910
"MD024": false

biome.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"files": {
3+
"includes": ["**", "!code/web-client/**", "!docs/assets/**"]
4+
}
5+
}

code/samples/python/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ dependencies = [
2222
"python-dotenv==1.2.2",
2323
"fastmcp==3.1.0",
2424
"cryptography==46.0.5",
25-
"web3==7.15.0"
25+
"web3==7.15.0",
26+
"rfc8785>=0.1.2",
2627
]
2728
keywords = ["payments", "a2a", "ap2"]
2829
readme = "README.md"
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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+
)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Makes the samples src/ tree importable without installing ap2-samples."""
2+
3+
import sys
4+
5+
from pathlib import Path
6+
7+
8+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'src'))

0 commit comments

Comments
 (0)