Skip to content

fix(sdk): enforce Payment Mandate closed checkout-binding by default in chain verify - #330

Open
vishkaty wants to merge 1 commit into
google-agentic-commerce:mainfrom
vishkaty:fix/enforce-payment-mandate-checkout-binding
Open

fix(sdk): enforce Payment Mandate closed checkout-binding by default in chain verify#330
vishkaty wants to merge 1 commit into
google-agentic-commerce:mainfrom
vishkaty:fix/enforce-payment-mandate-checkout-binding

Conversation

@vishkaty

@vishkaty vishkaty commented Aug 10, 2026

Copy link
Copy Markdown

fix(sdk): fail closed when a payment chain closed checkout binding is unverifiable (#328)

Closes #328.

Observed vs expected

PaymentMandateChain.verify() treats expected_transaction_id as optional and
checks the closed mandate transaction_id only when the caller supplies it. 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 behavior: verification should not succeed unless the
closed checkout binding is confirmed.

Runtime reproduction (main @ e1ea56d, code/sdk/python)

Mismatched but accepted (the gap):

# open mandate authorizes OPEN_HASH; closed mandate binds a DIFFERENT jwt hash
chain.verify(expected_open_checkout_hash=OPEN_HASH)
# -> []      (accepted)

Supplied correctly (proves the comparison 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 ...']

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): 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 and never inspects the closed
    transaction_id.

Spec grounding

docs/ap2/security_and_privacy_considerations.md L19-21, under 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 open one by default. The
closed transaction_id is 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_hash embedded in the transaction_id securely links the payment
to the associated Checkout Mandate" (L69).

UCP cross check: the closed transaction_id hashes the per session Checkout JWT
that 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. If expected_transaction_id is absent or blank the
closed 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 required
transaction_id field accepts "" and a caller may pass data.get(..., "").

Legitimate callers are never trapped. Any holder of a full chain is downstream of
a Checkout JWT, since the required transaction_id is that JWT hash, so a
settlement 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 surface
minimal and un relaxable.

Design alternatives considered and rejected:

  • Deriving the expectation internally from the chain. Rejected: the authoritative
    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_id hashes a different artifact (the open checkout
    mandate), confirmed by the processor passing two distinct values. Comparing
    transaction_id to itself would be a tautology that binds nothing.
  • A boolean require_transaction_binding=False opt out. Rejected in favor of the
    existing check_payment_constraints() because a boolean can be re silenced at
    one 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_default reproduces [Bug]: Payment mandate checkout binding depends on optional verifier context #328 and is
    the red to green anchor.
  • test_default_verify_protects_legitimate_caller covers the total bypass case
    (open mandate with no constraints).
  • test_blank_expected_transaction_id_fails_closed covers the "" and blank case.
  • test_matching_closed_binding_passes proves a genuinely matching chain still
    passes (no legit trap).
  • test_mismatched_closed_binding_still_flagged_when_supplied keeps the supplied
    correctly control.
  • test_constraints_only_honest_exit_is_check_payment_constraints documents the
    bounded 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 check and ruff format --check are clean on both changed files under the
repo .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.

Site Status Note
PaymentMandateChain.verify / expected_transaction_id Converted this fix
CheckoutMandateChain.verify / checkout_jwt Not vulnerable already fails closed when absent
PaymentReferenceEvaluator / open_checkout_hash Not vulnerable fails closed when a payment.reference constraint is present
AgentRecurrence and Budget / mandate_context Not vulnerable fail closed when the constraint needing context is present
CheckoutMandateChain.verify / expected_checkout_hash Same class, deferred distinct spec clause (L46-53 checkout_hash to checkout_jwt); the fail closed form also needs merchant_agent_mcp/server.py:881 and 3 SDK tests updated, so it warrants its own issue
Open side vacuity when no payment.reference exists Deferred an unconditional requirement would trap recurring or budget open mandates that legitimately span multiple checkouts; the closed side fix already backstops each individual settlement
MandateClient.verify / expected_aud, expected_nonce Out (filed) #319, PRs #313/#326
ExecutionDateEvaluator / execution_date Out (filed) #317, PR #318

Coupled caller fix (included)

x402_psp_mcp/server.py verified a payment chain but passed only
expected_open_checkout_hash, even though checkout_jwt_hash is already in scope
(the settle_payment parameter, forwarded by the merchant agent and the trigger
server). Without this the sample would fail closed under the SDK change, so this PR
also passes expected_transaction_id=checkout_jwt_hash there. With it, the x402
PSP 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_time parameter to the same verify() (execution
window) 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.py makes cspell-action
(incremental_files_only) re-scan that whole sample file, which surfaced
pre-existing domain terms (fastmcp, keccak, usdc, gwei, levelname,
sdjwt, Toggleable) — none at the changed line. Added them to
.cspell/custom-words.txt so the gate passes. No prose words introduced.

@vishkaty
vishkaty requested a review from a team as a code owner August 10, 2026 22:12
…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
vishkaty force-pushed the fix/enforce-payment-mandate-checkout-binding branch from a296e8f to 7bbecb0 Compare August 10, 2026 22:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Payment mandate checkout binding depends on optional verifier context

1 participant