Skip to content

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

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

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

Conversation

@arjun2075

Copy link
Copy Markdown

fix(sdk): verify checkout mandate binding from presented checkout

Closes #358.

Observed vs expected

CheckoutMandateChain.verify() evaluates the supplied checkout_jwt against the
open mandate's constraints but never derives its hash. The closed mandate's
checkout_hash is only compared against the optional expected_checkout_hash
argument, so omitting that argument skips the closed-binding check 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 defect is the binding never being derived, not the comparison itself.

Exact location

code/sdk/python/ap2/sdk/checkout_mandate_chain.py:44-110 (verify): the
checkout_hash comparison was gated on expected_checkout_hash is not None,
and the method never hashed the checkout_jwt it parsed a few lines earlier.

Spec grounding

docs/ap2/specification.md:311-312, 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, Verification → Dispute, independently
requires computing the hash 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 fix

verify() now computes the hash of the exact serialized checkout_jwt argument
it was asked to verify and compares it with closed_mandate.checkout_hash. The
check runs unconditionally, so it cannot be skipped by omitting an argument.

The hashed value is the checkout_jwt parameter — the checkout presented for
execution — not self.closed_mandate.checkout_jwt. Hashing the embedded copy
would only prove the mandate is internally consistent and would bind nothing.

Design alternatives considered and rejected:

  • Making expected_checkout_hash mandatory (the fail-closed shape fix(sdk): enforce Payment Mandate closed checkout-binding by default in chain verify #330 used
    for PaymentMandateChain). Rejected here because the two cases genuinely
    differ: PaymentMandateChain does not receive the external Checkout JWT, so
    its caller must supply the expected binding. CheckoutMandateChain.verify()
    already receives the actual checkout_jwt, so it can and should derive the
    hash itself rather than requiring callers to hash it correctly.
  • String equality against closed_mandate.checkout_jwt. Rejected: AP2
    specifies the binding through checkout_hash, so the implementation should
    verify the protocol binding rather than a parallel invariant.

Open design question: _sd_alg

I would appreciate maintainer direction here.

This patch calls the existing compute_sha256_b64url() helper. That is correct
for the SDK's current SHA-256 issuance path, but it is not fully correct for
every AP2-valid mandate, and I would rather surface that than paper over it.

docs/ap2/checkout_mandate.md:26-28 requires checkout_hash to use the
SD-JWT's _sd_alg, defaulting to sha-256 only when absent, and the SDK's own
SD-JWT layer already supports sha-256, sha-384 and sha-512
(_HASH_BY_SD_ALG in sdk/sdjwt/common.py).

The obstacle is an abstraction boundary: CheckoutMandateChain.parse() receives
only the effective verified payloads, by which point _sd_alg has been lost. It
lives on ParsedToken.payload, and the generated CheckoutMandate model has no
such field with pydantic extras disabled — confirmed by passing
_sd_alg: 'sha-512' into CheckoutMandate.model_validate() and observing
model_extra is None.

Supporting other algorithms correctly would mean retaining the verified closed
token's hash algorithm through the chain API. I deliberately kept that
API/data-flow change out of this patch to keep it narrow, and did not invent a
value or claim generic support the code does not have. Two options if you'd like
it addressed:

  1. Thread the algorithm (or the ParsedToken) into parse() / verify(), in
    this PR or a follow-up.
  2. Leave as-is while SHA-256 is the only issuance path, and treat non-default
    _sd_alg as a known gap.

Happy to follow your preference. Every checkout_hash producer in this repo
today uses SHA-256 (e.g. merchant_agent/tools.py:207), so this patch matches
current issuance behavior.

Treatment of expected_checkout_hash

Retained, and redefined as a secondary consistency assertion documented as such.
The security invariant no longer depends on it.

I kept it rather than removing it because it is a positional-first public
parameter and the only verify() caller in the repo
(merchant_agent_mcp/server.py:881) never passes it, so removal would be a
breaking change for no security benefit. If you'd prefer it deprecated or
removed, I'm glad to do that — I saw no existing deprecation pattern here, so I
left it for a separate change.

test_checkout_binding_not_skipped_when_expected_hash_agrees proves a
caller-supplied hash cannot mask a mismatched presented checkout.

Caller impact

None. merchant_agent_mcp/server.py:881 passes
chain.closed_mandate.checkout_jwt; well-formed mandates verify unchanged, and
the sample now additionally detects an internally inconsistent mandate. The
SDK owns the invariant, so no hashing is duplicated into the sample.

Tests

code/sdk/python/ap2/tests/checkout_mandate_chain_tests.py (6 → 13):

  • test_checkout_binding_rejects_other_checkout_without_expected_hash — binds A,
    presents B, omits expected_checkout_hash, expects rejection. Red-to-green
    anchor.
  • test_checkout_binding_not_skipped_when_expected_hash_agrees — a satisfied
    optional assertion cannot mask a mismatched checkout.
  • test_checkout_binding_reported_with_constraints_satisfied — binding failure
    reported even when all open constraints pass.
  • test_checkout_binding_matching_checkout_passes — the bound checkout still
    verifies cleanly (no legitimate caller trapped).
  • test_checkout_binding_does_not_leak_checkout_jwt — the violation message does
    not embed JWT contents.
  • test_checkout_binding_missing_checkout_jwt_still_fails /
    ..._malformed_checkout_jwt_still_fails — existing fail-closed behavior
    preserved.

Four pre-existing tests used placeholder hashes (checkout_hash='hash',
'actual_hash') and passed only because verify() never derived the hash. They
now carry the real hash of their checkout JWT. test_checkout_fields_parsed keeps
its placeholder deliberately: it only calls extract_parsed_checkout_object()
and never verify().

Reverting only the SDK file and rerunning gives 4 failed / 9 passed, the anchor
failing on assert [] — the reported behavior. With the fix, 13 passed.

Verification

Scope

Two files: the SDK fix and its tests. No changes to constraints.py, schemas,
generated models, samples, or the normative docs. No new checkout constraint
types, and no default verdict introduced for the unresolved open-mandate omission
semantics — this patch is strictly the closed → execution integrity binding.

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.

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.
@arjun2075
arjun2075 requested a review from a team as a code owner September 17, 2026 20:29
cspell rescans the whole of checkout_mandate_chain_tests.py because the
file is in this PR's changed set, which surfaces the pre-existing
ec.SECP256R1() curve name on line 22. The term is untouched by this
change and already present on main.
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