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: 7 additions & 0 deletions .cspell/custom-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ emvco
endlocal
envoyproxy
esac
fastmcp
felixge
Fiuu
fontawesome
Expand All @@ -66,6 +67,7 @@ gradlew
Gravitee
groupcache
gson
gwei
Hashkey
honnef
hprof
Expand All @@ -83,6 +85,7 @@ jetbrains
Jetpack
jvmargs
Kaia
keccak
keepattributes
keepclassmembers
Klarna
Expand All @@ -93,6 +96,7 @@ ktor
Ktor
KXMYBJWNQ
Lazada
levelname
libpeerconnection
Lightspark
linenums
Expand Down Expand Up @@ -149,6 +153,7 @@ ropeproject
RPCURL
Rulebook
screenreaders
sdjwt
setlocal
sharedpref
Shopcider
Expand All @@ -165,10 +170,12 @@ stablecoins
stdr
stretchr
superfences
Toggleable
Truelayer
Trulioo
udpa
unmarshal
usdc
viewmodel
vulnz
Wallex
Expand Down
3 changes: 2 additions & 1 deletion code/samples/python/src/roles/x402_psp_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ def settle_payment(
)
parsed_chain = PaymentMandateChain.parse(payloads)
violations = parsed_chain.verify(
expected_open_checkout_hash=open_checkout_hash
expected_transaction_id=checkout_jwt_hash,
expected_open_checkout_hash=open_checkout_hash,
)
if violations:
return {
Expand Down
60 changes: 47 additions & 13 deletions code/sdk/python/ap2/sdk/payment_mandate_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,37 @@ def verify(
expected_open_checkout_hash: str | None = None,
mandate_context: MandateContext | None = None,
) -> list[str]:
"""Verifies the constraints of the payment mandate chain.
"""Verifies the constraints and checkout binding of a payment chain.

A closed Payment Mandate binds itself to the Checkout it authorizes via
its ``transaction_id`` (the base64url hash of the Checkout JWT); the
Security & Privacy model requires a verifier to confirm that binding
against the checkout it is actually processing (see ``docs/ap2/
security_and_privacy_considerations.md`` "Manipulated Checkout":
"The Payment Mandate MUST contain a reference to its associated
Checkout ... via ``transaction_id`` for closed Payment Mandates").

This method therefore **fails closed**: a settlement verifier must pass
``expected_transaction_id``, and if it is absent or blank the closed
binding cannot be confirmed and a violation is reported rather than the
check being silently skipped. ``expected_transaction_id`` MUST be
computed from the Checkout JWT the verifier is fulfilling; it MUST NOT
be read back out of the chain (doing so makes the comparison a
tautology and binds nothing).

A caller that deliberately performs a **constraints-only** check that is
not a settlement decision (for example offline policy analysis of an
archived chain, where no checkout is being processed) has a bounded,
honest exit: call :func:`ap2.sdk.constraints.check_payment_constraints`
directly. That is a distinct, self-describing entry point, so it can
never be mistaken for a full ``verify()`` in a call graph.

Args:
expected_transaction_id: Optional transaction ID to check against the
closed mandate's transaction_id.
expected_transaction_id: The Checkout JWT hash the verifier is
processing, checked against the closed mandate's ``transaction_id``.
Required (non-empty) to confirm the closed checkout binding.
expected_open_checkout_hash: Optional checkout hash to check against
the open mandate's checkout_reference.
the open mandate's ``payment.reference`` constraint.
mandate_context: Aggregated usage context for the mandate.

Returns:
Expand All @@ -57,8 +81,9 @@ def verify(
'payment_mandate_chain.verify',
'before',
{
'has_expected_transaction_id': expected_transaction_id
is not None,
'has_expected_transaction_id': bool(
expected_transaction_id and expected_transaction_id.strip()
),
'has_expected_open_checkout_hash': (
expected_open_checkout_hash is not None
),
Expand All @@ -72,14 +97,23 @@ def verify(
open_checkout_hash=expected_open_checkout_hash,
mandate_context=mandate_context,
)
if (
expected_transaction_id is not None
and expected_transaction_id != self.closed_mandate.transaction_id
):
# Fail closed: an absent OR blank expected value binds nothing. Mirror
# the falsy check the open-side PaymentReference evaluator already uses.
if expected_transaction_id and expected_transaction_id.strip():
if expected_transaction_id != self.closed_mandate.transaction_id:
violations.append(
'Payment transaction_id mismatch: expected'
f' {expected_transaction_id}, got'
f' {self.closed_mandate.transaction_id}'
)
else:
violations.append(
'Payment transaction_id mismatch: expected'
f' {expected_transaction_id}, got'
f' {self.closed_mandate.transaction_id}'
'Closed Payment Mandate checkout binding not verified: a '
'non-empty expected_transaction_id (the hash of the Checkout '
'JWT being processed) is required to bind the closed mandate '
'to its checkout. For a constraints-only check that is not a '
'settlement decision, call check_payment_constraints() '
'directly.'
)

_log_event(
Expand Down
139 changes: 137 additions & 2 deletions code/sdk/python/ap2/tests/payment_mandate_chain_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,38 @@

import pytest

from ap2.sdk.constraints import check_payment_constraints
from ap2.sdk.generated.open_payment_mandate import (
AmountRange,
OpenPaymentMandate,
PaymentReference,
)
from ap2.sdk.generated.types.amount import Amount
from ap2.sdk.payment_mandate_chain import PaymentMandateChain
from ap2.tests.conftest import make_cnf, sample_payment_mandate


# Two DISTINCT artifacts: the hash of the OPEN checkout mandate the user
# authorized, and the hash of the Checkout JWT actually being processed.
_OPEN_CHECKOUT_HASH = 'sha256-open-checkout-mandate'
_REAL_CHECKOUT_JWT_HASH = 'sha256-real-checkout-jwt'


def _chain_with_mismatched_closed_binding() -> PaymentMandateChain:
"""Open mandate authorizes one checkout; closed mandate binds another JWT."""
return PaymentMandateChain(
open_mandate=OpenPaymentMandate(
constraints=[
PaymentReference(conditional_transaction_id=_OPEN_CHECKOUT_HASH)
],
cnf={'jwk': {'kty': 'EC'}},
),
closed_mandate=sample_payment_mandate(
transaction_id='sha256-a-DIFFERENT-checkout-jwt'
),
)


def test_payment_chain_constraint_violation(
user_key,
user_public_key,
Expand Down Expand Up @@ -49,7 +72,9 @@ def test_payment_chain_constraint_violation(
key_or_provider=lambda _token: user_public_key,
)
chain = PaymentMandateChain.parse(payloads)
violations = chain.verify()
# sample_payment_mandate() binds transaction_id='tx_1'; supply it so this
# stays a pure amount-constraint test and not a binding failure too.
violations = chain.verify(expected_transaction_id='tx_1')
assert any('exceeds maximum' in v for v in violations)


Expand Down Expand Up @@ -117,7 +142,117 @@ def test_full_payment_end_to_end(
key_or_provider=lambda _token: user_public_key,
)
chain = PaymentMandateChain.parse(payloads)
violations = chain.verify()
# A legitimate verifier asserts the closed checkout binding (the JWT hash it
# is processing); sample_payment_mandate() uses transaction_id='tx_1'.
violations = chain.verify(expected_transaction_id='tx_1')
assert violations == []
assert chain.open_mandate.vct == 'mandate.payment.open.1'
assert chain.closed_mandate.transaction_id == 'tx_1'


# --- #328: closed Payment Mandate transaction_id binding must fail closed ---


def test_closed_binding_skipped_is_now_flagged_by_default():
"""#328 repro: a mismatched closed transaction_id must NOT pass silently.

Verifying with only ``expected_open_checkout_hash`` used to return ``[]``
even though the closed mandate binds a different Checkout JWT. After the
fix the missing closed-binding check is itself a violation (fail closed).
"""
chain = _chain_with_mismatched_closed_binding()
violations = chain.verify(expected_open_checkout_hash=_OPEN_CHECKOUT_HASH)
assert violations != [], (
'closed transaction_id binding was silently skipped (#328)'
)
assert any('transaction_id' in v for v in violations)


def test_default_verify_protects_legitimate_caller():
"""A caller using the bare default API is protected, not silently passed.

Worst case: the open mandate carries NO constraints, so nothing else fires.
Today ``verify()`` returns [] (total bypass). It must fail closed instead,
because it cannot confirm the closed Payment Mandate's checkout binding.
"""
chain = PaymentMandateChain(
open_mandate=OpenPaymentMandate(
constraints=[], cnf={'jwk': {'kty': 'EC'}}
),
closed_mandate=sample_payment_mandate(transaction_id='tx_whatever'),
)
assert chain.verify() != []


def test_matching_closed_binding_passes():
"""Legit flow is never trapped: correct expected_transaction_id passes."""
chain = PaymentMandateChain(
open_mandate=OpenPaymentMandate(
constraints=[
PaymentReference(conditional_transaction_id=_OPEN_CHECKOUT_HASH)
],
cnf={'jwk': {'kty': 'EC'}},
),
closed_mandate=sample_payment_mandate(
transaction_id=_REAL_CHECKOUT_JWT_HASH
),
)
violations = chain.verify(
expected_transaction_id=_REAL_CHECKOUT_JWT_HASH,
expected_open_checkout_hash=_OPEN_CHECKOUT_HASH,
)
assert violations == []


def test_mismatched_closed_binding_still_flagged_when_supplied():
"""The supplied-correctly control keeps flagging a real mismatch."""
chain = _chain_with_mismatched_closed_binding()
violations = chain.verify(
expected_transaction_id=_REAL_CHECKOUT_JWT_HASH,
expected_open_checkout_hash=_OPEN_CHECKOUT_HASH,
)
assert any('transaction_id mismatch' in v for v in violations)


def test_blank_expected_transaction_id_fails_closed():
"""A blank/empty expected_transaction_id binds nothing -> fail closed.

``transaction_id`` has no min_length, so a mandate can carry ''. A caller
that passes '' (e.g. ``data.get('checkout_jwt_hash', '')``) must not be
treated as having confirmed the binding.
"""
chain = PaymentMandateChain(
open_mandate=OpenPaymentMandate(constraints=[], cnf={'jwk': {}}),
closed_mandate=sample_payment_mandate(transaction_id=''),
)
for blank in ('', ' '):
violations = chain.verify(expected_transaction_id=blank)
assert violations != [], f'blank {blank!r} was accepted'
assert any('binding not verified' in v for v in violations)


def test_constraints_only_honest_exit_is_check_payment_constraints():
"""The bounded honest exit for non-settlement checks is a distinct API.

``verify()`` fails closed with no binding, but a caller that genuinely only
wants constraint evaluation calls ``check_payment_constraints`` directly --
a self-describing entry point that cannot be mistaken for full verify().
"""
chain = PaymentMandateChain(
open_mandate=OpenPaymentMandate(
constraints=[AmountRange(currency='USD', max=5000)],
cnf={'jwk': {'kty': 'EC'}},
),
closed_mandate=sample_payment_mandate(
payment_amount=Amount(amount=10000, currency='USD'),
),
)
# Full verify() fails closed (no checkout binding supplied).
assert chain.verify() != []
# The honest constraints-only exit reports the amount violation and adds
# no spurious binding violation.
constraint_violations = check_payment_constraints(
chain.open_mandate, chain.closed_mandate
)
assert any('exceeds maximum' in v for v in constraint_violations)
assert not any('binding not verified' in v for v in constraint_violations)
Loading