fix(sdk): enforce Payment Mandate closed checkout-binding by default in chain verify - #330
Open
vishkaty wants to merge 1 commit into
Conversation
…is unverifiable (google-agentic-commerce#328) Observed vs expected -------------------- `PaymentMandateChain.verify()` treats `expected_transaction_id` as optional and checks the closed mandate's `transaction_id` only when it is supplied. A verifier that passes only `expected_open_checkout_hash` therefore accepts a closed Payment Mandate whose `transaction_id` binds a DIFFERENT Checkout JWT than the one being processed. Expected: verification cannot succeed unless the closed checkout binding is confirmed. Runtime repro (AP2 main @ e1ea56d, code/sdk/python) --------------------------------------------------- Mismatched-but-accepted (the bug): chain.verify(expected_open_checkout_hash=OPEN_HASH) # closed tx binds a # different JWT -> [] # ACCEPTED Supplied-correctly control (proves the check works when given the value): chain.verify(expected_transaction_id=REAL_JWT_HASH, expected_open_checkout_hash=OPEN_HASH) -> ['Payment transaction_id mismatch: expected ..., got ...'] So the vulnerability is the optional default silently skipping the closed-binding check, not the comparison itself. Exact location -------------- code/sdk/python/ap2/sdk/payment_mandate_chain.py:38-93 (verify): the `transaction_id` comparison was gated on `expected_transaction_id is not None`. code/sdk/python/ap2/sdk/constraints.py:140-169 (PaymentReferenceEvaluator) enforces only the OPEN-side `payment.reference`; it never inspects the closed `transaction_id`. Spec grounding (both) --------------------- docs/ap2/security_and_privacy_considerations.md L19-21 ("Manipulated Checkout"): "The Payment Mandate MUST contain a reference to its associated Checkout. This is via `transaction_id` for closed Payment Mandates and the `mandate.payment.reference` constraint for open ones." BOTH bindings are mandatory; the SDK enforced only the latter by default. `transaction_id` is a REQUIRED field whose schema description is "base64url-encoded hash of the checkout_jwt ... uniquely identifying the checkout" (generated/payment_mandate.py:25). UCP cross-check: the closed `transaction_id` hashes the per-session Checkout JWT that UCP already carries; enforcing the closed binding strengthens and does not contradict UCP. Design rationale (fail closed without trapping legit callers) ------------------------------------------------------------- The authoritative value for the closed binding is the hash of the Checkout JWT the verifier is fulfilling, which is EXTERNAL to the chain (the chain holds only open+closed mandates; the open `conditional_transaction_id` hashes a different artifact, as the MPP passing two distinct values confirms). A chain cannot self-certify which external checkout it is bound to, so deriving the expectation internally (comparing `transaction_id` to itself) is a tautology that closes nothing. The fix therefore REQUIRES the caller to assert the checkout it is processing: `verify()` fails closed when `expected_transaction_id` is absent OR blank (mirroring the falsy guard the open-side evaluator already uses), reporting a violation instead of silently passing. Legitimate callers are never trapped: any holder of a full chain is downstream of a Checkout JWT (the required `transaction_id` IS that JWT's hash), and a caller doing constraints-only analysis that is not a settlement decision has a bounded, honest exit -- the existing public `check_payment_constraints()` -- a distinct, self-describing entry point that cannot be laundered to look like a full verify(). No boolean opt-out is introduced, keeping the surface minimal and un-relaxable. Dedup ----- Reporter mh-yu, no assignee, OPEN. No open PR addresses google-agentic-commerce#328 semantically. Two open PRs touch the same files on orthogonal concerns and rebase trivially: google-agentic-commerce#318 adds a `current_time` param to the same `verify()` (execution window), and google-agentic-commerce#326 edits the same test file (terminal KB aud/nonce). The x402_psp one-line caller change here textually overlaps google-agentic-commerce#310, which rewrites that settle block but keeps the vulnerable verify call unchanged (so google-agentic-commerce#310 does not fix the caller); the added keyword carries onto google-agentic-commerce#310 on rebase. Distinct from google-agentic-commerce#315/google-agentic-commerce#317/google-agentic-commerce#319/google-agentic-commerce#320/google-agentic-commerce#268. Class sweep (optional param that skips a required binding when omitted) ---------------------------------------------------------------------- CONVERTED PaymentMandateChain.verify / expected_transaction_id -- this fix. NOT VULN CheckoutMandateChain.verify / checkout_jwt -- already fails closed (returns a violation when absent). NOT VULN PaymentReferenceEvaluator / open_checkout_hash -- already fails closed when a payment.reference constraint is present. NOT VULN AgentRecurrence/Budget / mandate_context -- already fail closed when the constraint needing context is present. SIBLING CheckoutMandateChain.verify / expected_checkout_hash -- same class, deferred: distinct spec clause (L46-53 checkout_hash<->checkout_jwt) and its fail-closed form also requires updating merchant_agent_mcp:881 and 3 SDK tests that omit it; warrants its own issue. DEFERRED Open-side vacuity when no payment.reference constraint exists -- an unconditional requirement would trap recurring/budget open mandates, which legitimately span multiple checkouts; the closed-side fix already backstops each individual settlement. Warrants separate design. OUT (filed) MandateClient.verify / expected_aud,expected_nonce (google-agentic-commerce#319, PRs google-agentic-commerce#313/google-agentic-commerce#326); ExecutionDateEvaluator / execution_date (google-agentic-commerce#317, PR google-agentic-commerce#318).
vishkaty
force-pushed
the
fix/enforce-payment-mandate-checkout-binding
branch
from
August 10, 2026 22:18
a296e8f to
7bbecb0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix(sdk): fail closed when a payment chain closed checkout binding is unverifiable (#328)
Closes #328.
Observed vs expected
PaymentMandateChain.verify()treatsexpected_transaction_idas optional andchecks the closed mandate
transaction_idonly when the caller supplies it. Averifier that passes only
expected_open_checkout_hashtherefore accepts a closedPayment Mandate whose
transaction_idbinds a different Checkout JWT than the onebeing processed. Expected behavior: verification should not succeed unless the
closed checkout binding is confirmed.
Runtime reproduction (main @ e1ea56d,
code/sdk/python)Mismatched but accepted (the gap):
Supplied correctly (proves the comparison works when given the value):
The defect is the optional default silently skipping the closed binding check, not
the comparison itself.
Exact location
code/sdk/python/ap2/sdk/payment_mandate_chain.py:38-93(verify): thetransaction_idcomparison was gated onexpected_transaction_id is not None.code/sdk/python/ap2/sdk/constraints.py:140-169(PaymentReferenceEvaluator)enforces only the open side
payment.referenceand never inspects the closedtransaction_id.Spec grounding
docs/ap2/security_and_privacy_considerations.mdL19-21, under ManipulatedCheckout:
Both bindings are mandatory; the SDK enforced only the open one by default. The
closed
transaction_idis a required field described as the"base64url-encoded hash of the checkout_jwt ... uniquely identifying the checkout"
(
generated/payment_mandate.py:25), and the same document adds"The
checkout_hashembedded in thetransaction_idsecurely links the paymentto the associated Checkout Mandate" (L69).
UCP cross check: the closed
transaction_idhashes the per session Checkout JWTthat UCP already carries. Enforcing the closed binding strengthens the guarantee
UCP relies on and does not contradict any UCP requirement.
The fix
verify()now fails closed. Ifexpected_transaction_idis absent or blank theclosed binding cannot be confirmed, so a violation is reported instead of the
check being silently skipped. The blank guard mirrors the falsy check the open
side evaluator already uses (
if not open_checkout_hash), because a requiredtransaction_idfield accepts""and a caller may passdata.get(..., "").Legitimate callers are never trapped. Any holder of a full chain is downstream of
a Checkout JWT, since the required
transaction_idis that JWT hash, so asettlement verifier always has the value to assert. The three settlement callers
in this repo already pass it:
merchant_payment_processor_mcp/server.py:258,credentials_provider_mcp/server.py:199,x402_credentials_provider_mcp/server.py:139.A caller doing constraints only analysis that is not a settlement decision has a
bounded, honest exit: call the existing public
check_payment_constraints()directly, a distinct and self describing entry point that cannot be mistaken for a
full
verify()in a call graph. No boolean opt out is added, keeping the surfaceminimal and un relaxable.
Design alternatives considered and rejected:
value is the hash of the external Checkout JWT the verifier is fulfilling. The
chain holds only the open and closed mandates, and the open
conditional_transaction_idhashes a different artifact (the open checkoutmandate), confirmed by the processor passing two distinct values. Comparing
transaction_idto itself would be a tautology that binds nothing.require_transaction_binding=Falseopt out. Rejected in favor of theexisting
check_payment_constraints()because a boolean can be re silenced atone remove (a partial or wrapper), and it duplicates an API that already exists.
Tests
code/sdk/python/ap2/tests/payment_mandate_chain_tests.py:test_closed_binding_skipped_is_now_flagged_by_defaultreproduces [Bug]: Payment mandate checkout binding depends on optional verifier context #328 and isthe red to green anchor.
test_default_verify_protects_legitimate_callercovers the total bypass case(open mandate with no constraints).
test_blank_expected_transaction_id_fails_closedcovers the""and blank case.test_matching_closed_binding_passesproves a genuinely matching chain stillpasses (no legit trap).
test_mismatched_closed_binding_still_flagged_when_suppliedkeeps the suppliedcorrectly control.
test_constraints_only_honest_exit_is_check_payment_constraintsdocuments thebounded honest exit.
Two pre existing tests that encoded the weak default were updated to assert the
closed binding a settlement verifier already knows (
test_full_payment_end_to_end,test_payment_chain_constraint_violation).Verification: full SDK suite runs 192 passed with 2 pre existing unrelated
failures in
kb_sd_jwt_intermediate_tests.py(issue #319, open PRs #313/#326).ruff checkandruff format --checkare clean on both changed files under therepo
.ruff.toml.Why CI did not catch it
The repository runs no Python test job today (workflows are lint, docs, spellcheck,
conventional commits, release please). Adding SDK and sample regression tests is in
flight in #325. This change adds coverage that would fail loudly if the default
regressed.
Class sweep
The class is: an optional by default parameter in a mandate or chain verify path
that silently skips a required binding or constraint when omitted.
PaymentMandateChain.verify/expected_transaction_idCheckoutMandateChain.verify/checkout_jwtPaymentReferenceEvaluator/open_checkout_hashpayment.referenceconstraint is presentAgentRecurrenceandBudget/mandate_contextCheckoutMandateChain.verify/expected_checkout_hashcheckout_hashtocheckout_jwt); the fail closed form also needsmerchant_agent_mcp/server.py:881and 3 SDK tests updated, so it warrants its own issuepayment.referenceexistsMandateClient.verify/expected_aud,expected_nonceExecutionDateEvaluator/execution_dateCoupled caller fix (included)
x402_psp_mcp/server.pyverified a payment chain but passed onlyexpected_open_checkout_hash, even thoughcheckout_jwt_hashis already in scope(the
settle_paymentparameter, forwarded by the merchant agent and the triggerserver). Without this the sample would fail closed under the SDK change, so this PR
also passes
expected_transaction_id=checkout_jwt_hashthere. With it, the x402PSP settles on a matching binding and rejects a mismatched one, verified end to end.
Courtesy note on overlap: this one line touches the same settle block that the open
#310 is rewriting (fail closed when the agent provider key is missing). #310 keeps
the verify call as is, so it does not fix this caller; the added keyword carries
onto #310 trivially on rebase, whichever merges first. Happy to coordinate ordering.
Dedup
Issue #328 is open, reporter mh-yu, no assignee. No open PR addresses #328
semantically. Two open PRs touch the same files on orthogonal concerns and rebase
trivially: #318 adds a
current_timeparameter to the sameverify()(executionwindow) and #326 edits the same test file (terminal KB aud and nonce). This is
distinct from the other optional parameter findings already tracked (#317, #319,
#320, #315, #268).
Spellcheck
The one-line caller change in
x402_psp_mcp/server.pymakescspell-action(
incremental_files_only) re-scan that whole sample file, which surfacedpre-existing domain terms (
fastmcp,keccak,usdc,gwei,levelname,sdjwt,Toggleable) — none at the changed line. Added them to.cspell/custom-words.txtso the gate passes. No prose words introduced.