Skip to content

fix(sdk): verify checkout mandate binding from presented checkout - #359

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

arjun2075 wants to merge 1 commit into
google-agentic-commerce:mainfrom
arjun2075:fix/checkout-mandate-binding

Conversation

@arjun2075

@arjun2075 arjun2075 commented Sep 17, 2026 •

Copy link
Copy Markdown

Closes #358.

Problem

CheckoutMandateChain.verify() validates the supplied checkout_jwt against the open mandate constraints, but it did not derive that JWT's hash and compare it with the closed mandate's checkout_hash.

Instead, the binding check was only performed when the optional expected_checkout_hash argument was supplied. Omitting that argument skipped the closed → execution binding entirely.

closed_mandate.checkout_hash = H(checkout A)

verify(checkout_jwt=checkout B)
# expected_checkout_hash omitted

Before: []
After:  ['Checkout checkout_hash mismatch: computed <B>, got <A>']

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_hash in the Checkout Mandate.

The Checkout Mandate documentation likewise defines checkout_hash as the base64url-encoded hash of checkout_jwt.

Fix

verify() now computes the hash of the exact serialized checkout_jwt argument being verified and compares it with:

closed_mandate.checkout_hash

The check is unconditional, so callers cannot bypass it by omitting expected_checkout_hash.

Importantly, the implementation hashes the presented checkout_jwt parameter, not self.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_hash

expected_checkout_hash is 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_alg

This patch uses the existing compute_sha256_b64url() helper, matching every current checkout_hash producer 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_alg has 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_alg support should be handled here or separately.

Tests

code/sdk/python/ap2/tests/checkout_mandate_chain_tests.py now has 14 tests.

New regression coverage includes:

  • test_checkout_binding_rejects_other_checkout_without_expected_hash

    • closed mandate binds checkout A
    • checkout B is presented
    • expected_checkout_hash omitted
    • verification rejects B
  • test_checkout_chain_rejects_internally_inconsistent_closed_mandate

    • closed_mandate.checkout_jwt = A
    • closed_mandate.checkout_hash = H(B)
    • presented checkout = A
    • verification rejects the inconsistent mandate
  • test_checkout_binding_not_skipped_when_expected_hash_agrees

    • a satisfied caller-provided expectation cannot mask a different presented checkout
  • test_checkout_binding_reported_with_constraints_satisfied

    • the integrity failure is still reported when all open-mandate constraints pass
  • test_checkout_binding_matching_checkout_passes

    • correctly bound checkouts continue to verify successfully
  • test_checkout_binding_does_not_leak_checkout_jwt

    • violation messages do not expose JWT contents
  • existing 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_parsed intentionally keeps its placeholder because it does not call verify().

The new internal-consistency regression test was also verified red-to-green:

pre-fix implementation: FAIL
this PR:                PASS

Verification

pytest code/sdk/python/ap2/tests/checkout_mandate_chain_tests.py
14 passed
pytest code/sdk/python
194 passed, 2 failed

The two broader-suite failures are pre-existing on main and unrelated to this change:

  • test_verify_rejects_aud_mismatch
  • test_verify_rejects_nonce_mismatch

They are tracked in #319.

Also verified:

ruff check                 clean
ruff format --check        clean
git diff --check           clean

Scope

This PR is limited to the closed Checkout Mandate → presented checkout integrity binding.

Changed files:

  • SDK verification logic
  • checkout mandate chain regression tests
  • one cspell custom-word entry for the pre-existing SECP256R1 identifier surfaced by the changed test file

No changes to:

  • schemas
  • generated models
  • samples
  • normative documentation
  • checkout constraint types
  • unresolved open-mandate omission semantics

@arjun2075
arjun2075 requested a review from a team as a code owner September 17, 2026 20:29
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.

This branch has not been deployed

No deployments
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]: Closed Checkout Mandate binding not enforced by CheckoutMandateChain.verify

1 participant