fix(sdjwt): honor expected aud/nonce on every KB hop - #313
Conversation
|
Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA). View this failed invocation of the CLA check for more information. For the most up to date status, view the checks section at the bottom of the pull request. |
d50e31d to
b0ffebe
Compare
|
@googlebot I signed it! (commits re-authored to the signing email) |
|
@googlebot rescan |
6244749 to
44ad0bb
Compare
|
@googlebot rescan |
0679ba5 to
fef421f
Compare
|
@googlebot rescan |
|
CI note: the Lint Code Base failure is the repo-wide Biome run (75 errors in |
62e9a81 to
f2074f5
Compare
|
Reviewed this independently against current main. The intermediate-hop gap is real and reproduces. The second change (a terminal hop that carries On CI: the two red checks are |
f2074f5 to
1d91c47
Compare
|
Thanks for the careful review. Happy to split: I can move the fail-closed-on-terminal change into a follow-up so the two-test fix lands clean ahead of #325. I'll wait for a maintainer to confirm that's the preferred shape before reshuffling, since the replay exposure only fully closes with the second half. |
|
@SashaMIT that shape works, and splitting sequences the hardening rather than dropping it. Your caveat is the right one: the replay exposure only fully closes with the second half, so the mandatory-presence change (a terminal hop carrying The split reads the same from every side that has looked at it. On the contract side, @giorgioroth set out on #310 why these are two properties: the supplied-expectation fix restores what the So the shape for a maintainer to confirm: keep #313 to the supplied-expectation fix that greens the two intermediate-hop tests and unblocks @arjun2075's #325, then mandatory presence as a follow-up. Once you reshuffle I am happy to review both. |
verify() only called verify_expected_claims under if typ in TYP_TERMINAL, so on an intermediate hop (typ kb+sd-jwt+kb) a caller that passes expected_aud or expected_nonce got no check at all. That is why test_verify_rejects_aud_mismatch and test_verify_rejects_nonce_mismatch fail on main. Move the check out of the terminal guard so it runs on every hop. Matches the verify() docstring (expected match is a general check) and create() (which already requires aud and nonce on every hop). Mandatory presence of expected_aud/expected_nonce when a terminal hop carries those claims is intentionally deferred to a follow-up so this change can land as a clean green baseline for google-agentic-commerce#325. Signed-off-by: SashaMIT <sash.t.mitchell@gmail.com>
1d91c47 to
ee37922
Compare
|
Reshuffled as agreed.
Happy for a re-review of both whenever you have a moment. |
|
Re-reviewed both against current main ( #313 — supplied-expectation fix. The reshuffle is exactly the shape agreed: the diff is the one-line move of #326 — mandatory presence. This is the correct home for the stronger change. The terminal-only guard is right, it runs after the supplied-expectation check so a mismatched value still gets the specific error, and the two new negative tests ( One scope note for a possible later follow-up (not a change request on #326): the mandatory check fires on claim presence ( |
|
Thanks for the re-review @chopmob-cloud — glad the reshuffle matches the agreed shape and the suite stays green. Holding #313 to the supplied-expectation fix only; happy to wait on a Google code-owner pass for merge ahead of #325. |
…is unverifiable (google-agentic-commerce#328) Observed vs expected -------------------- `PaymentMandateChain.verify()` treats `expected_transaction_id` as optional and checks the closed mandate's `transaction_id` only when it is supplied. A verifier that passes only `expected_open_checkout_hash` therefore accepts a closed Payment Mandate whose `transaction_id` binds a DIFFERENT Checkout JWT than the one being processed. Expected: verification cannot succeed unless the closed checkout binding is confirmed. Runtime repro (AP2 main @ e1ea56d, code/sdk/python) --------------------------------------------------- Mismatched-but-accepted (the bug): chain.verify(expected_open_checkout_hash=OPEN_HASH) # closed tx binds a # different JWT -> [] # ACCEPTED Supplied-correctly control (proves the check works when given the value): chain.verify(expected_transaction_id=REAL_JWT_HASH, expected_open_checkout_hash=OPEN_HASH) -> ['Payment transaction_id mismatch: expected ..., got ...'] So the vulnerability is the optional default silently skipping the closed-binding check, not the comparison itself. Exact location -------------- code/sdk/python/ap2/sdk/payment_mandate_chain.py:38-93 (verify): the `transaction_id` comparison was gated on `expected_transaction_id is not None`. code/sdk/python/ap2/sdk/constraints.py:140-169 (PaymentReferenceEvaluator) enforces only the OPEN-side `payment.reference`; it never inspects the closed `transaction_id`. Spec grounding (both) --------------------- docs/ap2/security_and_privacy_considerations.md L19-21 ("Manipulated Checkout"): "The Payment Mandate MUST contain a reference to its associated Checkout. This is via `transaction_id` for closed Payment Mandates and the `mandate.payment.reference` constraint for open ones." BOTH bindings are mandatory; the SDK enforced only the latter by default. `transaction_id` is a REQUIRED field whose schema description is "base64url-encoded hash of the checkout_jwt ... uniquely identifying the checkout" (generated/payment_mandate.py:25). UCP cross-check: the closed `transaction_id` hashes the per-session Checkout JWT that UCP already carries; enforcing the closed binding strengthens and does not contradict UCP. Design rationale (fail closed without trapping legit callers) ------------------------------------------------------------- The authoritative value for the closed binding is the hash of the Checkout JWT the verifier is fulfilling, which is EXTERNAL to the chain (the chain holds only open+closed mandates; the open `conditional_transaction_id` hashes a different artifact, as the MPP passing two distinct values confirms). A chain cannot self-certify which external checkout it is bound to, so deriving the expectation internally (comparing `transaction_id` to itself) is a tautology that closes nothing. The fix therefore REQUIRES the caller to assert the checkout it is processing: `verify()` fails closed when `expected_transaction_id` is absent OR blank (mirroring the falsy guard the open-side evaluator already uses), reporting a violation instead of silently passing. Legitimate callers are never trapped: any holder of a full chain is downstream of a Checkout JWT (the required `transaction_id` IS that JWT's hash), and a caller doing constraints-only analysis that is not a settlement decision has a bounded, honest exit -- the existing public `check_payment_constraints()` -- a distinct, self-describing entry point that cannot be laundered to look like a full verify(). No boolean opt-out is introduced, keeping the surface minimal and un-relaxable. Dedup ----- Reporter mh-yu, no assignee, OPEN. No open PR addresses google-agentic-commerce#328 semantically. Two open PRs touch the same files on orthogonal concerns and rebase trivially: google-agentic-commerce#318 adds a `current_time` param to the same `verify()` (execution window), and google-agentic-commerce#326 edits the same test file (terminal KB aud/nonce). The x402_psp one-line caller change here textually overlaps google-agentic-commerce#310, which rewrites that settle block but keeps the vulnerable verify call unchanged (so google-agentic-commerce#310 does not fix the caller); the added keyword carries onto google-agentic-commerce#310 on rebase. Distinct from google-agentic-commerce#315/google-agentic-commerce#317/google-agentic-commerce#319/google-agentic-commerce#320/google-agentic-commerce#268. Class sweep (optional param that skips a required binding when omitted) ---------------------------------------------------------------------- CONVERTED PaymentMandateChain.verify / expected_transaction_id -- this fix. NOT VULN CheckoutMandateChain.verify / checkout_jwt -- already fails closed (returns a violation when absent). NOT VULN PaymentReferenceEvaluator / open_checkout_hash -- already fails closed when a payment.reference constraint is present. NOT VULN AgentRecurrence/Budget / mandate_context -- already fail closed when the constraint needing context is present. SIBLING CheckoutMandateChain.verify / expected_checkout_hash -- same class, deferred: distinct spec clause (L46-53 checkout_hash<->checkout_jwt) and its fail-closed form also requires updating merchant_agent_mcp:881 and 3 SDK tests that omit it; warrants its own issue. DEFERRED Open-side vacuity when no payment.reference constraint exists -- an unconditional requirement would trap recurring/budget open mandates, which legitimately span multiple checkouts; the closed-side fix already backstops each individual settlement. Warrants separate design. OUT (filed) MandateClient.verify / expected_aud,expected_nonce (google-agentic-commerce#319, PRs google-agentic-commerce#313/google-agentic-commerce#326); ExecutionDateEvaluator / execution_date (google-agentic-commerce#317, PR google-agentic-commerce#318).
The repo-wide BIOME_LINT job fails on pre-existing code/web-client/** errors unrelated to this PR (same red on google-agentic-commerce#313/google-agentic-commerce#325). Add a root biome.json that scopes Biome off the web-client, per chopmob-cloud's note; took Lint Code Base green on google-agentic-commerce#279. Co-authored-by: Cursor <cursoragent@cursor.com>
The repo-wide BIOME_LINT job fails on pre-existing code/web-client/** errors unrelated to this PR (same red on google-agentic-commerce#313/google-agentic-commerce#325). Add a root biome.json that scopes Biome off the web-client, per chopmob-cloud' note; took Lint Code Base green on google-agentic-commerce#279.
Summary
verify()only calledverify_expected_claimsunderif typ in TYP_TERMINAL, so on an intermediate hop (typ kb+sd-jwt+kb) a caller that passesexpected_audorexpected_noncegot no check at all. That is whytest_verify_rejects_aud_mismatchandtest_verify_rejects_nonce_mismatchinkb_sd_jwt_intermediate_tests.pyfail on main.Move the check out of the terminal guard so it runs on every hop. Matches the
verify()docstring (expected match is a general check) andcreate()(which already requiresaudandnonceon every hop).Scope note (split)
Per discussion with @chopmob-cloud (and the contract / deployment / CI sequencing from @giorgioroth / AlgoVoi / @arjun2075), this PR is only the supplied-expectation fix. The stronger behaviour change — a terminal hop that carries
aud/noncemust be bound even when the caller passed no expectation — lands as a follow-up with its own compatibility note and tests, so this change can green the two intermediate-hop tests and unblock #325 without settling a protocol question by default.Test plan
test_verify_rejects_aud_mismatch,test_verify_rejects_nonce_mismatchexpected_aud/expected_nonceunchangedCI
cla/googleis green. Lint Code Base is the repo-wide Biome scan from #306 and also fails on main; unrelated to this Python-only diff.