Skip to content

fix(sdjwt): honor expected aud/nonce on every KB hop - #313

Open
SashaMIT wants to merge 1 commit into
google-agentic-commerce:mainfrom
SashaMIT:fix/kb-sdjwt-aud-nonce-binding
Open

fix(sdjwt): honor expected aud/nonce on every KB hop#313
SashaMIT wants to merge 1 commit into
google-agentic-commerce:mainfrom
SashaMIT:fix/kb-sdjwt-aud-nonce-binding

Conversation

@SashaMIT

@SashaMIT SashaMIT commented Aug 5, 2026

Copy link
Copy Markdown

Summary

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 in kb_sd_jwt_intermediate_tests.py 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).

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/nonce must 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

  • Intermediate-hop mismatch tests now pass: test_verify_rejects_aud_mismatch, test_verify_rejects_nonce_mismatch
  • Existing terminal-hop tests that already pass expected_aud/expected_nonce unchanged
  • No new fail-closed-on-unbound behaviour in this PR

CI

cla/google is green. Lint Code Base is the repo-wide Biome scan from #306 and also fails on main; unrelated to this Python-only diff.

@SashaMIT
SashaMIT requested a review from a team as a code owner August 5, 2026 20:55
@google-cla

google-cla Bot commented Aug 5, 2026

Copy link
Copy Markdown

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.

@SashaMIT
SashaMIT force-pushed the fix/kb-sdjwt-aud-nonce-binding branch 2 times, most recently from d50e31d to b0ffebe Compare August 6, 2026 10:41
@SashaMIT

SashaMIT commented Aug 6, 2026

Copy link
Copy Markdown
Author

@googlebot I signed it! (commits re-authored to the signing email)

@SashaMIT

SashaMIT commented Aug 6, 2026

Copy link
Copy Markdown
Author

@googlebot rescan

@SashaMIT
SashaMIT force-pushed the fix/kb-sdjwt-aud-nonce-binding branch from 6244749 to 44ad0bb Compare August 6, 2026 11:30
@SashaMIT

SashaMIT commented Aug 6, 2026

Copy link
Copy Markdown
Author

@googlebot rescan

@SashaMIT
SashaMIT force-pushed the fix/kb-sdjwt-aud-nonce-binding branch 2 times, most recently from 0679ba5 to fef421f Compare August 6, 2026 12:31
@SashaMIT

SashaMIT commented Aug 6, 2026

Copy link
Copy Markdown
Author

@googlebot rescan

@SashaMIT

SashaMIT commented Aug 6, 2026

Copy link
Copy Markdown
Author

CI note: the Lint Code Base failure is the repo-wide Biome run (75 errors in code/web-client TS files, also failing on main at run 25120930358). This PR touches Python SDK + tests only, so none of the reported diagnostics come from this change. The remaining blocker here is cla/google.

@chopmob-cloud

Copy link
Copy Markdown

Reviewed this independently against current main.

The intermediate-hop gap is real and reproduces. verify() only calls 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 gets no check at all. That is why test_verify_rejects_aud_mismatch and test_verify_rejects_nonce_mismatch in kb_sd_jwt_intermediate_tests.py fail on main. Moving verify_expected_claims out of the terminal guard so it runs on every hop is the correct minimal fix, and it matches the documented contract: the verify() docstring states the expected match as a general check, and create() already requires aud and nonce on every hop, so both claims are always issued and should always be checkable.

The second change (a terminal hop that carries aud or nonce but is verified with no expected value now raises) is a stronger and separate behaviour change. It converts a previously silent pass into an error for existing callers, which is why seven in-tree tests had to start binding. It is defensible on replay grounds, but it decides a protocol-semantics point rather than only turning the two red tests green, so it reads as a maintainer call. Splitting it into its own follow-up would let the two-test fix land immediately as a clean green baseline (which the CI-only work in #325 depends on) without waiting on the semantics discussion.

On CI: the two red checks are cla/google (your signing) and Lint Code Base, which is the repo-wide Biome scan from #306 and also fails on main. That Biome result is unrelated to this diff and clears once the linter-workflow change reaches main and this branch rebases onto it.

@SashaMIT
SashaMIT force-pushed the fix/kb-sdjwt-aud-nonce-binding branch from f2074f5 to 1d91c47 Compare August 10, 2026 07:09
@SashaMIT

Copy link
Copy Markdown
Author

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.

@chopmob-cloud

Copy link
Copy Markdown

@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 aud or nonce must be bound even when the caller passed no expectation) should still land, as its own PR with a compatibility note and its own tests.

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 verify() docstring already documents (expected values are matched when provided, and the next docstring line conditions on hop type only for cnf), whereas mandatory presence moves a rule from issuer to verifier. On the production side, we at AlgoVoi run live payment verification, and mandatory presence is the half that would reject tokens deployed verifiers and issuers accept today, including ones this SDK did not emit, so it needs its own compatibility window. And the sequencing is @arjun2075's: land the behavioural fix first, then run the CI-only #325 against a green baseline, which also keeps a red test from settling a protocol question by default. Contract reading, deployment view and CI ordering all point the same way.

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>
@SashaMIT
SashaMIT force-pushed the fix/kb-sdjwt-aud-nonce-binding branch from 1d91c47 to ee37922 Compare August 10, 2026 11:06
@SashaMIT SashaMIT changed the title fix(sdjwt): enforce aud/nonce binding on every KB hop, fail closed on terminal fix(sdjwt): honor expected aud/nonce on every KB hop Aug 10, 2026
@SashaMIT

Copy link
Copy Markdown
Author

Reshuffled as agreed.

Happy for a re-review of both whenever you have a moment.

@chopmob-cloud

Copy link
Copy Markdown

Re-reviewed both against current main (e1ea56d), built and run in a clean venv.

#313 — supplied-expectation fix. The reshuffle is exactly the shape agreed: the diff is the one-line move of verify_expected_claims out of the if typ in TYP_TERMINAL guard plus the explanatory comment (the only other change is two cspell allowlist entries). Baseline main reproduces the gap, test_verify_rejects_aud_mismatch and test_verify_rejects_nonce_mismatch both fail "DID NOT RAISE". On this branch both go green and nothing regresses: the two kb_sd_jwt test files are 23/23 and the whole Python SDK suite is 188 passed. verify_expected_claims compares aud/nonce with exact equality and is a no-op when the caller passes no expectation, so this only tightens intermediate hops for callers who already bind, with no new false rejects. LGTM to land as the clean baseline #325 depends on.

#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 (test_verify_rejects_terminal_aud_left_unbound, test_verify_rejects_terminal_nonce_left_unbound) plus the in-tree call sites that now bind bring the full suite to 190 passed. The compatibility note names the real exposure precisely: an unbound terminal verifier turned any captured presentation into a replayable bearer credential. The RFC 9901 §7.3 citation checks out, "Verification by the Verifier", step 5(f) requires the Verifier validate nonce and aud for replay detection, exactly the property this enforces. Approve the approach; merge after #313 since it is based on that branch.

One scope note for a possible later follow-up (not a change request on #326): the mandatory check fires on claim presence ('aud' in payload), so a terminal token minted with no aud/nonce at all still verifies. AP2-profile create() always emits both, so conformant tokens are covered; enforcing that terminal hops MUST carry them is an issuer-side invariant that could be a separate verify-path check if the group wants defence against non-conformant issuers.

@SashaMIT

Copy link
Copy Markdown
Author

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.

vishkaty added a commit to vishkaty/AP2 that referenced this pull request Aug 10, 2026
…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).
SashaMIT added a commit to SashaMIT/AP2 that referenced this pull request Aug 11, 2026
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>
SashaMIT added a commit to SashaMIT/AP2 that referenced this pull request Aug 11, 2026
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.
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.

2 participants