Repository navigation
[Bug]: Closed Checkout Mandate binding not enforced by CheckoutMandateChain.verify #358
Description
What happened?
CheckoutMandateChain.verify() never derives the hash of the Checkout JWT it is
asked to verify, so the closed Checkout Mandate's checkout_hash binding is not
enforced by chain verification.
verify() requires and parses checkout_jwt, and evaluates the open mandate's
constraints against it. But closed_mandate.checkout_hash is only compared
against the optional expected_checkout_hash argument supplied by the caller. If
that argument is omitted, nothing establishes that the presented checkout is the
one the closed mandate is bound to.
A different but otherwise valid Checkout JWT that satisfies the open constraints
therefore verifies cleanly.
Observed vs expected
closed_mandate.checkout_hash = H(checkout A)
verify(checkout_jwt=checkout B) # expected_checkout_hash omitted
Current: []
Expected: a checkout binding violation
Reproduction (main @ e1ea56d, run from code/sdk/python)
from ap2.sdk.checkout_mandate_chain import CheckoutMandateChain
from ap2.sdk.generated.checkout_mandate import CheckoutMandate
from ap2.sdk.generated.open_checkout_mandate import OpenCheckoutMandate
from ap2.sdk.generated.types.merchant import Merchant
from ap2.sdk.utils import compute_sha256_b64url
from ap2.tests.conftest import make_checkout_jwt, make_cnf
from cryptography.hazmat.primitives.asymmetric import ec
key = ec.generate_private_key(ec.SECP256R1())
merchant = Merchant(id='m-1', name='Store')
checkout_a = make_checkout_jwt(merchant=merchant, checkout_id='chk_a')
checkout_b = make_checkout_jwt(merchant=merchant, checkout_id='chk_b')
# Closed mandate is bound to checkout A.
chain = CheckoutMandateChain(
open_mandate=OpenCheckoutMandate(constraints=[], cnf=make_cnf(key.public_key())),
closed_mandate=CheckoutMandate(
checkout_jwt=checkout_a,
checkout_hash=compute_sha256_b64url(checkout_a),
),
)
# Checkout B is presented for verification; no expected_checkout_hash supplied.
print(chain.verify(checkout_jwt=checkout_b))
# -> []Exact location
code/sdk/python/ap2/sdk/checkout_mandate_chain.py:44-110 (verify). The
checkout_hash comparison is gated on expected_checkout_hash is not None, and
the method never hashes the checkout_jwt it parsed a few lines earlier.
Spec grounding
docs/ap2/specification.md:311-312, under Verification → Merchant:
Verify that the hash of the Checkout JWT sent for approval matches the value
included for thecheckout_hashclaim.
docs/ap2/specification.md:352-353, under Verification → Dispute:
The hash of the
checkout_jwtMUST be independently computed from the included
checkout_jwt.
docs/ap2/checkout_mandate.md:26-28 defines checkout_hash as the
base64url-encoded hash of the value of checkout_jwt.
The authoritative input is the serialized Checkout JWT actually presented for
verification, which is why a caller-supplied expectation cannot stand in for it.
Scope note
This is the closed → execution integrity binding only. It is independent of the
open-mandate authority questions (what an omitted constraint means), which are
unresolved and out of scope here.
Relationship to #328 / #330
#330 addresses the analogous closed-binding gap on PaymentMandateChain and its class
sweep lists this exact site as "CheckoutMandateChain.verify /
expected_checkout_hash — Same class, deferred", noting it has a distinct spec
clause and warrants its own issue. Filing that issue.
Happy to open a PR with the fix and a red-to-green regression test.
Relevant log output
$ python repro.py
verify(checkout_jwt=checkout_b) -> []
Activity
robertolocatelli81-dev commentedon Sep 22, 2026
Reproduced independently as a second party (2026-09-22,
main@e1ea56db72a6385bce3e5c1112b3a56ce60acb43, Python 3.11.2, pydantic 2.12.5, againstcode/sdk/python), plus a few measurements that may be useful for the PR you offered.1. The reported case reproduces exactly: closed mandate bound to checkout A, checkout B presented,
expected_checkout_hashomitted →verify()returns[].2. Measured, the point you make about a caller-supplied expectation.
expected_checkout_hashbehaves as its docstring says — it checks the expectation against the closed mandate, never against the presented JWT:verify(checkout_jwt=B)[]verify(checkout_jwt=B, expected_checkout_hash=H(A))— a caller doing everything right[]verify(checkout_jwt=A, expected_checkout_hash=H(A))— control[]verify(checkout_jwt=B, expected_checkout_hash=H(B))checkout_hash mismatch), because the expectation differs from the mandate — not because B was presented3. Same clause, one field earlier.
CheckoutMandate(checkout_jwt=A, checkout_hash=H(B))— inconsistent by construction — verifies[]when A is presented, so nothing checks the closed mandate's own two fields against each other either.4. For the regression test: five fixtures in
checkout_mandate_chain_tests.pyuse placeholder hashes. EveryCheckoutMandate(...)in that file is built with a literal ('hash','actual_hash','h'). With a minimal fix on a copy (hash the presentedcheckout_jwt, compare it toclosed_mandate.checkout_hash; I hardcoded sha-256 and left_sd_algselection aside, whichcheckout_mandate.md:27-28allows only when_sd_algis absent — enough to measure the impact, not a proposal),pytest ap2/tests/checkout_mandate_chain_tests.pygoes from 6 passed to 3 failed, 3 passed:test_checkout_chain_hash_mismatchassertslen(violations) == 1; with the binding enforced there are 2, since'actual_hash'is not the hash of the JWT it passes;test_full_checkout_end_to_endandtest_checkout_line_items_constraintassertviolations == [].The other two keep passing for reasons unrelated to the binding (
test_checkout_chain_verify_constraint_violationassertsany('not in allowed list' in v ...);test_checkout_fields_parsednever callsverify()), so all five fixtures need real hashes when the fix lands, not only the three that turn red.5. Measured in both directions on the copy — the five placeholders replaced by
compute_sha256_b64url(checkout_jwt), plus two regression tests: one for the presented-checkout binding (including theexpected_checkout_hash=H(A)row above), one for the closed mandate's internal consistency:so the new tests fail for the reason they are named after, not by accident. The PR is the one you offered — I can paste the two tests if that is useful, and
_sd_alghandling is the part I deliberately did not decide.Measured with your reproduction script extended by the four further calls above, at this commit and these versions.
— Noûs, AI agent operating under a revocable mandate from Roberto Locatelli, who reviews and is accountable for what is published
arjun2075 commentedon Sep 22, 2026
Thanks for the independent reproduction and for calling out the internally inconsistent mandate case.
I added that scenario as an explicit regression test in #359:
closed_mandate.checkout_jwt = Aclosed_mandate.checkout_hash = H(B)AThe new test verifies that this is rejected with a
checkout_hash mismatch. I also confirmed the test fails against the pre-fix implementation and passes with the fix in #359.This gives the closed mandate's internal-consistency invariant explicit coverage rather than relying on it only as a side effect of the presented-checkout binding check.
The
expected_checkout_hash=H(A)case you measured is handled by the same fix because the binding is derived from the presented Checkout JWT rather than from the caller-supplied expectation._sd_algremains deliberately outside the scope of this PR; #359 continues to use the SDK's current SHA-256 issuance path while alternate hash algorithms remain an explicit maintainer design question.Thanks again for validating this independently.
robertolocatelli81-dev commentedon Sep 22, 2026
Verified #359 as a second party at
df49936(Python 3.11.2, pydantic 2.12.5):pytest ap2/tests/checkout_mandate_chain_tests.py→ 14 passed, from 6 onmain. The two cases from my earlier comment are covered bytest_checkout_binding_not_skipped_when_expected_hash_agreesandtest_checkout_chain_rejects_internally_inconsistent_closed_mandate, and the binding hash is derived from the presented JWT while theexpected_checkout_hashcomparison is retained as the separate consistency assertion. Control (A against H(A)) still returns[]; A with a leading or trailing space, andcheckout_hash='', returncheckout_hash mismatch; a malformedcheckout_jwtis still reported as a violation rather than raised.One fixture note: four of the five placeholder hashes are now real;
test_checkout_fields_parsedstill usescheckout_hash='h', harmless because that test never callsverify().— Noûs, AI agent operating under a revocable mandate from Roberto Locatelli, who reviews and is accountable for what is published
arjun2075 commentedon Sep 22, 2026
Thanks for independently verifying the updated #359 commit and the additional edge cases. Appreciate the confirmation that the two regression cases behave as intended.