fix(samples): fail closed in x402 PSP when agent-provider key is missing - #310
Conversation
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
The x402 PSP settle_payment skipped SD-JWT mandate verification when the agent-provider public key could not be loaded, then continued to binding, ecrecover and settlement. The signature check and the Payment Mandate constraint evaluation were both guarded by "if agent_provider_pub:" with no else, so a missing or unreadable key silently disabled verification. Two sibling roles already fail closed on the same condition: merchant_agent_mcp and merchant_payment_processor_mcp both return an error when the key cannot be loaded. This makes the settlement role consistent by returning agent_provider_key_missing before any binding or settlement step. Adds regression tests: one proves the fail-closed return when the key is missing, the other proves verification still runs (and can reject) when the key is present. Also adds the domain terms already used in this file to the cspell dictionary so the touched file passes the spellcheck job. Refs google-agentic-commerce#309 Signed-off-by: AlgoVoi <chopmob@gmail.com>
1ee4ef9 to
34459e3
Compare
|
Thanks for picking this up. Two things worth recording, since between us the branch is now covered from both sides. You reproduced the key-absent path reaching Step 1 binding, which my report explicitly did not establish. I had run the flow with the key present and confirmed verification occurs. It is no longer a source observation. The diff reads correctly to me. Both loss paths leave One thing you fixed that was not in my report: the On the failing check — BIOME_LINT is the only one red, and this PR touches three Python files. That looks like the condition described in #306, where BIOME_LINT scans beyond the PR diff. The question my issue asked is still open and is not yours to answer: whether the asymmetry was intentional for local demonstration. The fix is right either way. |
|
One remaining corrupt-key edge showed up when I ran this commit against a semantically invalid JWK. With I verified that importing |
JWK.from_json() can fail with more than OSError/ValueError/JSONDecodeError:
jwcrypto raises JWException for valid-JSON-but-invalid-JWK (e.g. "{}"), and a
non-object JSON value (e.g. "[]", "null", "123") raises TypeError. Both
escaped the key-load guard and propagated instead of returning
agent_provider_key_missing.
Catch broadly at the key-load boundary so any failure to load or validate the
key fails closed via the existing guard, rather than enumerating library
exception types. Add a parametrized regression test covering invalid-JWK,
non-object-JSON and empty-file inputs.
Signed-off-by: AlgoVoi <chopmob@gmail.com>
|
Thanks @Silentpartnercoding, confirmed and fixed. While adding that, an adversarial pass over the key-load path turned up a related family: a key file that is valid JSON but not an object ( The regression test is now parametrized over invalid-JWK, non-object-JSON and empty-file inputs. Full suite passes locally (7 tests), and an adversarial battery of malformed key files all fail closed with none reaching binding or settlement. Thanks also @giorgioroth for the review. The BIOME_LINT red looks like the #306 condition (it scans beyond the PR diff, and this PR touches only Python), unrelated to these changes. |
Super-linter's BIOME_LINT scans the whole web-client (issue google-agentic-commerce#306), so pre-existing a11y and type diagnostics in these three files surface as a red Lint Code Base check on any PR. Fix them at the source: * add type="button" to non-submit buttons (useButtonType) * add aria-hidden to decorative SVG icons (noSvgWithoutTitle) * type the import.meta env access instead of any (noExplicitAny) * use optional chaining under the existing hasCurrentPrice guard (behavior identical) instead of a non-null assertion (noNonNullAssertion) * suppress useExhaustiveDependencies where the messages dep is an intentional scroll trigger, and useSemanticElements on the fully keyboard-accessible role=button row, both with explanatory reasons No formatting changes and no behavior changes. biome lint is clean (0 errors, 0 warnings) across the whole web-client after this change. Signed-off-by: AlgoVoi <chopmob@gmail.com>
This reverts commit ebb61e7. Signed-off-by: AlgoVoi <chopmob@gmail.com>
super-linter enables both Biome and ESLint for JS/TS and warns they conflict. The Biome lint reports pre-existing a11y/type findings across web-client and docs/assets files unrelated to any given PR, so Lint Code Base fails on nearly every PR (including dependabot). Disable VALIDATE_BIOME_LINT; ESLint coverage for those files is retained, so the check reflects the PR's real changes. Editing this workflow can activate the GitHub Actions zizmor audit, so also make the workflow pass it cleanly: * pin actions/checkout and super-linter to commit SHAs (unpinned-uses, High) * add an explicit minimal permissions block (excessive-permissions, Medium) * set persist-credentials: false on checkout (artipacked, Medium) zizmor reports no findings on this workflow under the default persona. Signed-off-by: AlgoVoi <chopmob@gmail.com>
|
I noticed the note here that the new sample regression tests currently don't run in CI. I checked the workflows on Given that #300, #301, and this PR are adding regression coverage around payment authorization and fail-closed behavior, would maintainers be open to a small follow-up PR that adds a scoped Python CI job for the SDK and security-relevant sample tests? I'd keep it separate from this fix and limited to test execution — no protocol or sample behavior changes. Happy to put together a small version using the repo's preferred dependency/setup conventions if that would be useful. |
|
Good check, and you are right that nothing runs the Python suites today. Please do open the scoped CI job PR you proposed, that is the right home for it and better coming from you. One finding to save you a red first run: two existing SDK tests already fail on current |
|
Thanks — that sounds good. I'd prefer to keep the existing Once that lands (or is otherwise resolved), I'll open the scoped Python CI PR against the green baseline. I'll use the repo's existing uv setup and keep the workflow limited to the SDK/sample regression suites we discussed. Appreciate the heads-up — that saved exactly the kind of confusing first red run CI is supposed to help us avoid. |
|
One small suggestion before kb_sd_jwt.verify moves: worth confirming the intended contract for expected_aud and expected_nonce on intermediate hops first, since the two tests in code/sdk/python/ap2/tests/kb_sd_jwt_intermediate_tests.py already read more like a description of that contract than of current behaviour - both name their values correct against a wrong expectation, match on an aud mismatch / nonce mismatch message, and build a hop carrying cnf for the next one. The spec side points the same way: aud and nonce are the values that bind a presentation to a verifier and a request. Whether that binding is intended to hold at every hop or only at the terminal one is the question. If it holds at every hop, then the tests are expressing the contract and the implementation is what is out of step. Either way, the ordering you two settled on is the important part: a small behavioral PR for those two, then the CI-only PR against a green baseline. That keeps the test runner from accidentally deciding protocol semantics merely because an existing test is red. |
|
Agreed, and I think the repo settles the contract question on its own. The implementation is the part out of step: So the change stays small: enforce the expected values |
|
Thanks again for the encouragement — I opened the scoped CI work as Draft PR #325: I kept it limited to CI/test execution only. The draft currently treats #310 and #313 as upstream prerequisites, so I expect it to remain blocked until those land. Once both are on |
|
Thanks @chopmob-cloud, happy to join on the contract and adversarial-review side. The property looks settled now: if verify is given an expected_aud or expected_nonce, that expectation should participate in verification regardless of whether the hop is intermediate or terminal. Otherwise the caller supplies a constraint that is silently ignored. I’ll take a look at #313 from the contract and adversarial side - tests, boundary, and failure cases. |
|
Quick coordination note so we do not double up. The behavioural fix for those two red tests ( The ordering we settled still holds. #313 lands the behavioural fix and gives the green baseline, then @arjun2075's CI-only #325 runs against it. @giorgioroth, glad to have you on the contract and adversarial side of #313; that is where the remaining judgement call sits, whether the stronger terminal-binding change rides along or splits out. |
|
I think these are two separate properties and should be split. The minimal fix says a supplied constraint cannot be silently ignored. verify() documents that supplied expected_aud / expected_nonce are matched, with no restriction to terminal hops - and the very next line of the same docstring does condition on hop type, for cnf. So the terminal-only guard around verify_expected_claims(...) reads as a discrepancy with the documented behaviour rather than an unwritten rule. create() requiring both on every hop means the claims are there to be checked; it is evidence about what this implementation emits, not yet about what verify() must accept. The stronger change is a different property: it says the binding must be present even when the caller supplied no expectation. That moves a rule from the issuer to the verifier, changes what counts as an acceptable hop, and can refuse tokens that verify today - including ones this SDK did not produce. I'd keep #313 to the documented supplied-expectation fix that restores the green baseline #325 depends on, and take mandatory presence separately, with its own tests and a compatibility rationale. |
…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).
Summary
The x402 PSP
settle_paymentskipped SD-JWT mandate verification when the agent-provider public key could not be loaded, and then continued to binding, ecrecover and settlement. The signature check and the Payment Mandate constraint evaluation were both insideif agent_provider_pub:with no else, so a missing or unreadable key silently disabled verification (a fail-open path).Two sibling roles already fail closed on exactly this condition:
merchant_agent_mcp/server.pyreturnsno_public_key/agent_provider_key_missingmerchant_payment_processor_mcp/server.pyreturnsagent_provider_key_missingThis change makes the settlement role consistent: it returns
agent_provider_key_missingwhen the key cannot be loaded, before any binding or settlement step. The verification block itself is unchanged, only de-indented now that the guard returns early.Verification
Reproduced the branch directly against
settle_payment(it is a plain function under@mcp.tool()):mandate_verification_failed(verification runs).agent_provider_key_missing, binding is never reached.Tests
Adds
code/samples/python/tests/:settle_payment_failclosed_tests.py: proves the fail-closed return when the key is missing, and that verification still runs (and rejects) when the key is present.conftest.py: puts the samplessrconsys.pathso the role modules import when the suite runs fromcode/samples/python.Both pass locally on Python 3.12. No CI job currently runs the sample pytest suite, so this is a local regression guard.
Notes
x402_psp_mcp/server.py(fastmcp,sdjwt,keccak,toggleable,usdc,gwei,levelname,sepolia) to.cspell/custom-words.txtso the touched file passes the spellcheck job, which checks the whole changed file.Refs #309