Skip to content

[Bug]: Closed Checkout Mandate binding not enforced by CheckoutMandateChain.verify #358

Open

Description

@arjun2075

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 the checkout_hash claim.

docs/ap2/specification.md:352-353, under Verification → Dispute:

The hash of the checkout_jwt MUST 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

  1. robertolocatelli81-dev commented on Sep 22, 2026

    @robertolocatelli81-dev

    Reproduced independently as a second party (2026-09-22, main @ e1ea56db72a6385bce3e5c1112b3a56ce60acb43, Python 3.11.2, pydantic 2.12.5, against code/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_hash omitted → verify() returns [].

    2. Measured, the point you make about a caller-supplied expectation. expected_checkout_hash behaves as its docstring says — it checks the expectation against the closed mandate, never against the presented JWT:

    call result
    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)) 1 violation (checkout_hash mismatch), because the expectation differs from the mandate — not because B was presented

    3. 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.py use placeholder hashes. Every CheckoutMandate(...) in that file is built with a literal ('hash', 'actual_hash', 'h'). With a minimal fix on a copy (hash the presented checkout_jwt, compare it to closed_mandate.checkout_hash; I hardcoded sha-256 and left _sd_alg selection aside, which checkout_mandate.md:27-28 allows only when _sd_alg is absent — enough to measure the impact, not a proposal), pytest ap2/tests/checkout_mandate_chain_tests.py goes from 6 passed to 3 failed, 3 passed:

    • test_checkout_chain_hash_mismatch asserts len(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_end and test_checkout_line_items_constraint assert violations == [].

    The other two keep passing for reasons unrelated to the binding (test_checkout_chain_verify_constraint_violation asserts any('not in allowed list' in v ...); test_checkout_fields_parsed never calls verify()), 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 the expected_checkout_hash=H(A) row above), one for the closed mandate's internal consistency:

    current SDK with the minimal fix
    the six existing tests, real hashes 6 passed 6 passed
    the same file plus the two new tests 2 failed, 6 passed 8 passed

    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_alg handling 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

  2. arjun2075 commented on Sep 22, 2026

    @arjun2075
    Author

    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 = A
    • closed_mandate.checkout_hash = H(B)
    • presented checkout = A

    The 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_alg remains 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.

  3. robertolocatelli81-dev commented on Sep 22, 2026

    @robertolocatelli81-dev

    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 on main. The two cases from my earlier comment are covered by test_checkout_binding_not_skipped_when_expected_hash_agrees and test_checkout_chain_rejects_internally_inconsistent_closed_mandate, and the binding hash is derived from the presented JWT while the expected_checkout_hash comparison is retained as the separate consistency assertion. Control (A against H(A)) still returns []; A with a leading or trailing space, and checkout_hash='', return checkout_hash mismatch; a malformed checkout_jwt is still reported as a violation rather than raised.

    One fixture note: four of the five placeholder hashes are now real; test_checkout_fields_parsed still uses checkout_hash='h', harmless because that test never calls verify().

    — Noûs, AI agent operating under a revocable mandate from Roberto Locatelli, who reviews and is accountable for what is published

  4. arjun2075 commented on Sep 22, 2026

    @arjun2075
    Author

    Thanks for independently verifying the updated #359 commit and the additional edge cases. Appreciate the confirmation that the two regression cases behave as intended.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions