Repository navigation
Conversation
CheckoutMandateChain.verify() evaluated the supplied checkout_jwt against the open mandate's constraints but never derived its hash. The closed mandate's checkout_hash was only compared against an optional caller-provided expected_checkout_hash, so omitting that argument skipped the closed-mandate binding check entirely: a different but otherwise valid Checkout JWT that satisfied the open constraints verified cleanly. Derive the hash from the checkout_jwt being verified and compare it with the closed Checkout Mandate's checkout_hash, making the spec-required binding part of normal chain verification. expected_checkout_hash is retained as a secondary consistency assertion only. Tests cover a mismatched presented checkout with no caller-supplied hash, a caller-supplied hash that cannot mask a mismatch, and a closed mandate whose own checkout_jwt and checkout_hash refer to different checkouts. Per docs/ap2/specification.md (Verification -> Merchant), the Merchant MUST verify that the hash of the Checkout JWT sent for approval matches the checkout_hash claim. PR google-agentic-commerce#330 addresses the analogous Payment Mandate binding and identifies this as the same class of issue. SECP is added to the cspell word list because cspell rescans the whole of checkout_mandate_chain_tests.py once the file is in the changed set, which surfaces the pre-existing ec.SECP256R1() curve name.
arjun2075
force-pushed
the
fix/checkout-mandate-binding
branch
from
September 22, 2026 13:21
6466453 to
df49936
Compare
This branch has not been deployed
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.
Closes #358.
Problem
CheckoutMandateChain.verify()validates the suppliedcheckout_jwtagainst the open mandate constraints, but it did not derive that JWT's hash and compare it with the closed mandate'scheckout_hash.Instead, the binding check was only performed when the optional
expected_checkout_hashargument was supplied. Omitting that argument skipped the closed → execution binding entirely.The issue is therefore not the comparison itself; the binding was never derived from the checkout presented for execution.
Spec grounding
AP2 requires the Merchant to verify that the hash of the Checkout JWT sent for approval matches the
checkout_hashin the Checkout Mandate.The Checkout Mandate documentation likewise defines
checkout_hashas the base64url-encoded hash ofcheckout_jwt.Fix
verify()now computes the hash of the exact serializedcheckout_jwtargument being verified and compares it with:The check is unconditional, so callers cannot bypass it by omitting
expected_checkout_hash.Importantly, the implementation hashes the presented
checkout_jwtparameter, notself.closed_mandate.checkout_jwt.Hashing only the embedded copy would prove that the closed mandate is internally consistent, but would not prove that the checkout being executed is the one bound by that mandate.
expected_checkout_hashexpected_checkout_hashis retained as a secondary consistency assertion.The security invariant no longer depends on it.
I kept the parameter because it is part of the existing public API and removing it would create compatibility risk without improving the binding guarantee.
The regression tests also verify that a caller-supplied value matching the closed mandate cannot mask a different checkout presented to
verify()._sd_algThis patch uses the existing
compute_sha256_b64url()helper, matching every currentcheckout_hashproducer in this repository.There is a broader design question around
_sd_alg: the AP2 documentation allows the Checkout Mandate hash algorithm to follow the SD-JWT_sd_alg, with SHA-256 as the default, while the SDK's SD-JWT layer also supports SHA-384 and SHA-512.The current chain abstraction receives the effective verified mandate payload after
_sd_alghas been lost, so supporting alternate algorithms correctly would require carrying additional verified-token metadata through the chain API.I deliberately left that API/data-flow change out of this patch to keep the fix focused.
Happy to follow maintainer preference on whether alternate
_sd_algsupport should be handled here or separately.Tests
code/sdk/python/ap2/tests/checkout_mandate_chain_tests.pynow has 14 tests.New regression coverage includes:
test_checkout_binding_rejects_other_checkout_without_expected_hashexpected_checkout_hashomittedtest_checkout_chain_rejects_internally_inconsistent_closed_mandateclosed_mandate.checkout_jwt = Aclosed_mandate.checkout_hash = H(B)test_checkout_binding_not_skipped_when_expected_hash_agreestest_checkout_binding_reported_with_constraints_satisfiedtest_checkout_binding_matching_checkout_passestest_checkout_binding_does_not_leak_checkout_jwtexisting missing/malformed checkout fail-closed behavior remains covered
Four pre-existing verification tests used placeholder checkout hashes and only passed because the SDK never derived the hash. Those fixtures now use the actual hash of their checkout JWT.
test_checkout_fields_parsedintentionally keeps its placeholder because it does not callverify().The new internal-consistency regression test was also verified red-to-green:
Verification
The two broader-suite failures are pre-existing on
mainand unrelated to this change:test_verify_rejects_aud_mismatchtest_verify_rejects_nonce_mismatchThey are tracked in #319.
Also verified:
Scope
This PR is limited to the closed Checkout Mandate → presented checkout integrity binding.
Changed files:
SECP256R1identifier surfaced by the changed test fileNo changes to: