fix(sdjwt): require expected aud/nonce when a terminal KB hop carries them - #326
Conversation
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>
… them Follow-up to google-agentic-commerce#313. A terminal hop that carries aud/nonce but is verified without expected_aud/expected_nonce previously passed silently. Per RFC 9901 §7.3 a verifier MUST confirm a key-binding token's aud identifies itself; the AP2 profile always issues terminal hops with aud/nonce and exp is optional, so an unbound verifier turned every captured presentation into a replayable bearer credential. This is a deliberate behaviour change for existing callers that verified terminal presentations without binding those claims. Compatibility note: call sites must now pass expected_aud/expected_nonce when verifying a terminal hop that carries them. Signed-off-by: SashaMIT <sash.t.mitchell@gmail.com>
|
Reviewed alongside #313 (full detail in the re-review there). Built on |
|
One contract question on the scope note, not a change request on #326. I checked the delegation draft rather than inferring the rule from create(). §5.1.4 says KB-SD-JWTs MUST conform to all requirements of a KB-JWT and an SD-JWT except as listed below. The listed KB-JWT changes alter typ and the sd_hash / issuer_jwt_hash binding, but do not make aud or nonce optional. RFC 9901 §4.3 in turn defines both aud and nonce as REQUIRED for a KB-JWT. That matches this module's description of both hop variants carrying iat / aud / nonce, create() requiring both, and the README's Deviations section saying the terminal AP2 KB-SD-JWT carries them “by spec”. The Wire format section's description of the closed leaf as carrying iat plus “(optionally) aud/nonce” therefore appears inconsistent with the rest of that contract. So the format requirement itself looks fairly well specified. The remaining question seems to be where AP2 wants to enforce it: should verify() reject a KB-SD-JWT that omits REQUIRED aud or nonce, including externally produced hops, or is that conformance check intentionally being deferred for compatibility? I read that as a third property, distinct from both #313 and #326: KB-SD-JWT hop → aud and nonce MUST be present No request to fold this into #326 - I think keeping the compatibility-sensitive enforcement decision separate is still useful. |
|
Agree, and I checked the same sources first-hand rather than inferring from The one place that disagrees is the Wire format section: README line 149 describes the closed leaf as carrying I read it the same way you do: a distinct third property (KB-SD-JWT hop -> Happy to send the standalone PR for it if the group wants (a presence check in |
|
@arjun2075 flagging this for you given the CI/sequencing you set up in #325. giorgioroth's point above is a third, spec-level property (a KB-SD-JWT hop MUST carry |
|
Thanks @chopmob-cloud and @giorgioroth — reading it the same way. Agree the third property (KB-SD-JWT hop → @chopmob-cloud happy for you to take the standalone presence + README fix if you want; otherwise I can open it once a maintainer signals timing. Either way I will not fold it into #326. |
|
Thanks for flagging me — I read the sequencing the same way. On the I would keep the presence enforcement + README correction together as one standalone change after the current baseline settles. The existing README wording is worth calling out as a compatibility consideration, but unless maintainers know of consumers that require staged migration, I wouldn't preserve a spec-invalid shape solely for compatibility. Nothing needs to change in #325 — after these tests land on Separately, while tracing the verifier I noticed one adjacent invariant I'd like to sanity-check before proposing another fix. Draft §5.1.4 says that when a dSD-JWT is presented by a Delegate Holder to a Verifier, exactly one Current So, as I read it:
Is that relaxation intentional because If it's verifier-only and I'm not missing another enforcement layer, I'm happy to take a small standalone follow-up after the current I'd keep that completely separate from #313, #326, and #325. |
|
Thanks @SashaMIT and @arjun2075. On the documentation side: happy for you to take the README line 149 correction, Sasha, if you'd like it — we won't duplicate it, and no objection from us either way. @arjun2075 keeping the README correction together with the presence enforcement as one standalone change works for us too; the fix is unconditional, so it can ride with the enforcement or land on its own, whichever you two prefer. On enforce-now vs a compatibility window, that's the maintainers' call, and your point about not preserving a spec-invalid shape absent a known consumer needing staged migration is well made. One coordination note: Sasha taking the doc line and you bundling doc + enforcement both touch README line 149, so worth a quick word on who lands it to avoid a collision. On the |
|
Thanks — agreed on the coordination. @SashaMIT, if you're happy to take the README line 149 correction, please go ahead. In that case I'll keep any presence-enforcement follow-up scoped to code/tests only so we don't collide on the same line. If you'd rather leave the doc change with the enforcement PR, I'm happy to bundle it there instead. I'll also leave the enforce-now vs compatibility-window decision to the maintainers before opening that behavioral change. For the delegate_payload invariant, I'll keep it completely separate as suggested. Before proposing code there, I'd still like to confirm whether verify_chain() is intended specifically for Verifier presentations or also for Holder → Delegate Holder presentations, since that changes whether “exactly one” can be enforced unconditionally at that layer. Nothing further needed on #325 from my side for either of these. |
|
@SashaMIT the red This is what we did to get over it on our side: add a root {
"files": {
"includes": ["**", "!code/web-client"]
}
}That took |
|
Confirming the At the primitive layer your reading holds:
The compensating control is narrower than it looks. Only the two fixed-length parsers, That makes it a scoped conformance hardening at |
|
I checked the delegate_payload path more closely against current main, and I think the compensating controls narrow the ambiguity enough that this does look like a verifier-level follow-up. MandateClient.verify() routes delegation chains through verify_chain() and returns its raw per-hop payloads, while MandateClient.present() is the API that constructs/appends a new delegation hop. So I don't currently see the Holder → new Delegate Holder issuance case as requiring verify_chain() itself to accept multiple disclosed delegate payloads. The current behavior also seems to have two separate cardinality edges: _effective_payloads(..., require_single=True) rejects only > 1, so zero resolved items still pass; The fixed-length Payment/Checkout parsers catch some malformed results downstream, but MandateClient.verify() itself returns the raw chain payloads, including chains longer than the typed two-hop parsers support. Given draft §5.1.4 / §6's verifier-presentation requirement, I think a standalone follow-up after the current aud/nonce sequence would be reasonable. One implementation caveat: I wouldn't change _effective_payloads() globally from > 1 to != 1, because the root SD-JWT also passes require_single=True and legitimately may have no delegate_payload. I'd scope the exactly-one enforcement specifically to verified KB-SD-JWT hops and add negative vectors for zero/multiple disclosures on both intermediate and final hops, plus a regression that a root without delegate_payload remains valid. If maintainers agree with that interpretation, I'm happy to take that follow-up once the current SD-JWT PR sequence settles. |
|
One last contract check before this turns from a strong implementation reading into an enforced property. I think the current behavior is now well established: on the multi-hop verifier path, zero resolved delegate_payload values can pass where one appears required, and the final KB hop can admit multiple values at the verify_chain() layer. The fixed-length parsers narrow the exposure, but they do not cover every path returned by MandateClient.verify(). The remaining question is slightly different from whether the implementation gap exists: is MandateClient.verify() / verify_chain() explicitly the semantic enforcement boundary for the verifier-presentation cardinality rule? The call graph strongly suggests that it is - especially because present() owns construction/appending of delegation hops while verify() consumes and returns the verified chain. But I would rather not let the patch itself become the thing that implicitly defines that boundary. If maintainers confirm that verify_chain() is the verifier-presentation boundary for this purpose, then Arjun's proposed tests become a clean direct validation of the contract: zero/multiple resolved delegate_payload on intermediate KB hops → reject That would make the scope explicit first, then make the implementation conform to it - rather than inferring semantic ownership solely from where the current call graph happens to route the data. |
|
Following up on the third property, in case it is useful: I have a ready implementation of the presence enforcement plus the README line-149 correction, validated end to end.
Two negative tests sign a terminal hop that omits On who lands it: @arjun2075 you leaned toward enforcing this now as a standalone, and @SashaMIT you offered to let us take the standalone presence and README fix, or to open it yourself once a maintainer signals timing. I am happy to open this as a PR sequenced after #313/#326, or to hold it if you would rather own the enforcement, arjun. Either way it is built and green, so whatever avoids a collision on README line 149 works for me. |
|
Thanks — since you already have the presence enforcement, negative tests, README correction, and vectors implemented and green, my preference is that you go ahead with that standalone PR. I don't think there's value in me rebuilding the same change just for ownership. @SashaMIT, if that works for you too, I'd let the README line 149 correction ride with that PR so we avoid a doc collision. I'll keep the If that interpretation is confirmed, I'll scope the follow-up narrowly to KB-SD-JWT verifier hops:
I'll keep it independent of #313, #326, the presence/README change, and #325. Nothing needs to change in #325, it should simply pick up whichever regression tests eventually land on |
…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).
|
Thanks all. @arjun2075 @chopmob-cloud — works for me. Let the README line 149 correction ride with the standalone presence-enforcement PR so we avoid a doc collision. I'll keep #326 scoped to the bind-when-carried half only. @chopmob-cloud thanks for the On the |
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.
7191d62 to
38f451b
Compare
The biome.json from chopmob-cloud scoped off code/web-client, but this branch fails BIOME_LINT on pre-existing docs/assets/*.svg a11y/parse errors instead. Add those to the ignore list so Lint Code Base reflects only files this PR touches.
48a7bde to
588aeea
Compare
Summary
Follow-up to #313. A terminal hop that carries
aud/noncebut is verified withoutexpected_aud/expected_noncepreviously passed silently. Per RFC 9901 §7.3 a verifier MUST confirm a key-binding token'saudidentifies itself; the AP2 profile always issues terminal hops withaud/nonceandexpis optional, so an unbound verifier turned every captured presentation into a replayable bearer credential.Compatibility note
This is a deliberate behaviour change. Call sites that verified terminal presentations without passing
expected_aud/expected_noncewill now get an error. In-tree call sites are updated; external verifiers that accept tokens this SDK (or other AP2-profile issuers) emit today will need the same binding.Why a separate PR
Split from #313 so the supplied-expectation fix (two red intermediate-hop tests) can land as a clean green baseline for #325, and so this protocol-semantics change gets its own review and compatibility window. Sequencing agreed with @chopmob-cloud.
Test plan
test_verify_rejects_terminal_aud_left_unbound,test_verify_rejects_terminal_nonce_left_unboundexpected_aud/expected_nonceMade with Cursor