From 34459e398bc505e322bd67534b1d48671023836e Mon Sep 17 00:00:00 2001 From: AlgoVoi Date: Fri, 31 Jul 2026 09:44:00 +0100 Subject: [PATCH 1/5] fix(samples): fail closed in x402 PSP when agent-provider key is missing 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 #309 Signed-off-by: AlgoVoi --- .cspell/custom-words.txt | 8 +++ .../python/src/roles/x402_psp_mcp/server.py | 58 ++++++++++--------- code/samples/python/tests/conftest.py | 15 +++++ .../settle_payment_failclosed_tests.py | 45 ++++++++++++++ 4 files changed, 98 insertions(+), 28 deletions(-) create mode 100644 code/samples/python/tests/conftest.py create mode 100644 code/samples/python/tests/roles/x402_psp_mcp/settle_payment_failclosed_tests.py diff --git a/.cspell/custom-words.txt b/.cspell/custom-words.txt index ce73c361..d31fe3a0 100644 --- a/.cspell/custom-words.txt +++ b/.cspell/custom-words.txt @@ -47,6 +47,7 @@ emvco endlocal envoyproxy esac +fastmcp felixge Fiuu fontawesome @@ -66,6 +67,7 @@ gradlew Gravitee groupcache gson +gwei Hashkey honnef hprof @@ -83,6 +85,7 @@ jetbrains Jetpack jvmargs Kaia +keccak keepattributes keepclassmembers Klarna @@ -93,6 +96,7 @@ ktor Ktor KXMYBJWNQ Lazada +levelname libpeerconnection Lightspark linenums @@ -149,6 +153,8 @@ ropeproject RPCURL Rulebook screenreaders +sdjwt +sepolia setlocal sharedpref Shopcider @@ -165,10 +171,12 @@ stablecoins stdr stretchr superfences +toggleable Truelayer Trulioo udpa unmarshal +usdc viewmodel vulnz Wallex diff --git a/code/samples/python/src/roles/x402_psp_mcp/server.py b/code/samples/python/src/roles/x402_psp_mcp/server.py index 26370f04..03a29d00 100644 --- a/code/samples/python/src/roles/x402_psp_mcp/server.py +++ b/code/samples/python/src/roles/x402_psp_mcp/server.py @@ -114,35 +114,37 @@ def settle_payment( AGENT_PROVIDER_PUB_PATH.read_text(encoding="utf-8") ) except (OSError, ValueError, json.JSONDecodeError): - _logger.warning( - "Agent-provider public key not found — skipping SD-JWT verification" - ) - else: - _logger.warning( - "Agent-provider public key not found — skipping SD-JWT verification" - ) + _logger.exception("Failed to load agent-provider public key") - if agent_provider_pub: - try: - payloads = MandateClient().verify( - token=mandate_chain_str, - key_or_provider=lambda _token: agent_provider_pub, - expected_aud="credential-provider", - expected_nonce=payment_nonce, - ) - parsed_chain = PaymentMandateChain.parse(payloads) - violations = parsed_chain.verify( - expected_open_checkout_hash=open_checkout_hash - ) - if violations: - return { - "error": "mandate_verification_failed", - "message": "; ".join(violations), - } - _logger.info("SD-JWT mandate chain verified successfully") - except Exception as e: - _logger.exception("SD-JWT mandate verification failed") - return {"error": "mandate_verification_failed", "message": str(e)} + if not agent_provider_pub: + return { + "error": "agent_provider_key_missing", + "message": ( + "Agent-provider public key not found; cannot verify payment" + " mandate" + ), + } + + try: + payloads = MandateClient().verify( + token=mandate_chain_str, + key_or_provider=lambda _token: agent_provider_pub, + expected_aud="credential-provider", + expected_nonce=payment_nonce, + ) + parsed_chain = PaymentMandateChain.parse(payloads) + violations = parsed_chain.verify( + expected_open_checkout_hash=open_checkout_hash + ) + if violations: + return { + "error": "mandate_verification_failed", + "message": "; ".join(violations), + } + _logger.info("SD-JWT mandate chain verified successfully") + except Exception as e: + _logger.exception("SD-JWT mandate verification failed") + return {"error": "mandate_verification_failed", "message": str(e)} # 1. Verify Binding: Hash(Mandate) == Nonce _logger.info("Step 1: Verifying Binding") diff --git a/code/samples/python/tests/conftest.py b/code/samples/python/tests/conftest.py new file mode 100644 index 00000000..50e880ac --- /dev/null +++ b/code/samples/python/tests/conftest.py @@ -0,0 +1,15 @@ +"""Pytest configuration for the samples test-suite. + +Adds the samples ``src`` directory to ``sys.path`` so role modules such as +``roles.x402_psp_mcp.server`` and the shared ``common`` package import +cleanly when the suite runs from ``code/samples/python``. +""" + +import sys + +from pathlib import Path + + +_SRC = Path(__file__).resolve().parent.parent / 'src' +if str(_SRC) not in sys.path: + sys.path.insert(0, str(_SRC)) diff --git a/code/samples/python/tests/roles/x402_psp_mcp/settle_payment_failclosed_tests.py b/code/samples/python/tests/roles/x402_psp_mcp/settle_payment_failclosed_tests.py new file mode 100644 index 00000000..23bc3317 --- /dev/null +++ b/code/samples/python/tests/roles/x402_psp_mcp/settle_payment_failclosed_tests.py @@ -0,0 +1,45 @@ +"""Regression tests for the x402 PSP fail-closed fix (AP2 #309). + +When the agent-provider public key cannot be loaded, ``settle_payment`` +must fail closed and return ``agent_provider_key_missing`` instead of +silently skipping SD-JWT mandate verification. A second test proves that +verification still runs (and can fail) when the key IS present, so the +fix did not disable the check. +""" + +import json + +from jwcrypto.jwk import JWK +from roles.x402_psp_mcp import server +from roles.x402_psp_mcp.server import settle_payment + + +def _minimal_bundle(): + """Return the smallest bundle that reaches the key-loading guard.""" + return { + 'payment_mandate_chain': 'not-a-valid-sd-jwt', + 'eip_3009_payload': {'authorization': {'nonce': '0x00'}}, + 'payment_nonce': 'nonce-abc', + } + + +def test_settle_payment_fails_closed_when_key_missing(monkeypatch, tmp_path): + """Missing agent-provider key must fail closed, not skip verification.""" + missing = tmp_path / 'missing.pub' + monkeypatch.setattr(server, 'AGENT_PROVIDER_PUB_PATH', missing) + + result = settle_payment(payment_token=json.dumps(_minimal_bundle())) + + assert result['error'] == 'agent_provider_key_missing' + + +def test_settle_payment_still_verifies_when_key_present(monkeypatch, tmp_path): + """With the key present, verification still runs and rejects bad input.""" + pub_path = tmp_path / 'present.pub' + public_jwk = JWK.generate(kty='EC', crv='P-256').export_public() + pub_path.write_text(public_jwk, encoding='utf-8') + monkeypatch.setattr(server, 'AGENT_PROVIDER_PUB_PATH', pub_path) + + result = settle_payment(payment_token=json.dumps(_minimal_bundle())) + + assert result['error'] == 'mandate_verification_failed' From 0b39c1abf16d6f75247d531262563cf8ca2ebb3e Mon Sep 17 00:00:00 2001 From: AlgoVoi Date: Sat, 1 Aug 2026 21:49:27 +0100 Subject: [PATCH 2/5] fix(samples): fail closed on any key-load failure in x402 PSP 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 --- .../python/src/roles/x402_psp_mcp/server.py | 7 +++- .../settle_payment_failclosed_tests.py | 38 ++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/code/samples/python/src/roles/x402_psp_mcp/server.py b/code/samples/python/src/roles/x402_psp_mcp/server.py index 03a29d00..cbfa6e3f 100644 --- a/code/samples/python/src/roles/x402_psp_mcp/server.py +++ b/code/samples/python/src/roles/x402_psp_mcp/server.py @@ -113,7 +113,12 @@ def settle_payment( agent_provider_pub = JWK.from_json( AGENT_PROVIDER_PUB_PATH.read_text(encoding="utf-8") ) - except (OSError, ValueError, json.JSONDecodeError): + except Exception: # noqa: BLE001 + # Any failure to load or validate the key (missing, unreadable, + # malformed JSON, wrong JSON type, or an invalid JWK) must fail + # closed via the guard below, so catch broadly rather than + # enumerate library exception types (jwcrypto raises JWException, + # and a non-object JWK raises TypeError, neither a ValueError). _logger.exception("Failed to load agent-provider public key") if not agent_provider_pub: diff --git a/code/samples/python/tests/roles/x402_psp_mcp/settle_payment_failclosed_tests.py b/code/samples/python/tests/roles/x402_psp_mcp/settle_payment_failclosed_tests.py index 23bc3317..52d2b890 100644 --- a/code/samples/python/tests/roles/x402_psp_mcp/settle_payment_failclosed_tests.py +++ b/code/samples/python/tests/roles/x402_psp_mcp/settle_payment_failclosed_tests.py @@ -4,11 +4,17 @@ must fail closed and return ``agent_provider_key_missing`` instead of silently skipping SD-JWT mandate verification. A second test proves that verification still runs (and can fail) when the key IS present, so the -fix did not disable the check. +fix did not disable the check. A third test covers key files that parse as +JSON but are not a usable key (an invalid JWK object, which raises +``jwcrypto`` ``JWException``, and a non-object JSON value, which raises +``TypeError``) -- neither is a ``ValueError``/``JSONDecodeError``, and both +must fail closed rather than propagate. """ import json +import pytest + from jwcrypto.jwk import JWK from roles.x402_psp_mcp import server from roles.x402_psp_mcp.server import settle_payment @@ -43,3 +49,33 @@ def test_settle_payment_still_verifies_when_key_present(monkeypatch, tmp_path): result = settle_payment(payment_token=json.dumps(_minimal_bundle())) assert result['error'] == 'mandate_verification_failed' + + +@pytest.mark.parametrize( + 'key_content', + [ + '{}', # valid JSON object, invalid JWK -> jwcrypto JWException + '[]', # non-object JSON -> TypeError + 'null', # non-object JSON -> TypeError + '123', # non-object JSON -> TypeError + '', # empty file -> JSONDecodeError + ], +) +def test_settle_payment_fails_closed_when_key_is_unusable( + monkeypatch, tmp_path, key_content +): + """A file that is not a usable key must fail closed, not raise. + + ``JWK.from_json`` raises different exception types for these inputs + (``JWException`` for an invalid JWK object, ``TypeError`` for a + non-object JSON value, ``JSONDecodeError`` for empty/garbage text). + None is a ``ValueError``, so the key-load guard must catch broadly + and return ``agent_provider_key_missing`` for every one of them. + """ + bad_path = tmp_path / 'unusable.pub' + bad_path.write_text(key_content, encoding='utf-8') + monkeypatch.setattr(server, 'AGENT_PROVIDER_PUB_PATH', bad_path) + + result = settle_payment(payment_token=json.dumps(_minimal_bundle())) + + assert result['error'] == 'agent_provider_key_missing' From ebb61e7cdc1482e71e030308b4ef6fead6f73dfe Mon Sep 17 00:00:00 2001 From: AlgoVoi Date: Sat, 1 Aug 2026 22:23:17 +0100 Subject: [PATCH 3/5] fix(web-client): resolve Biome lint errors flagged on PR CI Super-linter's BIOME_LINT scans the whole web-client (issue #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 --- code/web-client/src/App.tsx | 7 ++++- .../src/components/InventoryOptionsCard.tsx | 6 +++-- .../src/components/MandateApproval.tsx | 27 +++++++++++++------ 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/code/web-client/src/App.tsx b/code/web-client/src/App.tsx index a6ddb758..2df75933 100644 --- a/code/web-client/src/App.tsx +++ b/code/web-client/src/App.tsx @@ -25,7 +25,8 @@ const AppHeader = ({usedServers}: {usedServers: Set}) => { }, ]; - const flow = (import.meta as any).env?.VITE_FLOW; + const flow = (import.meta as unknown as {env?: Record}) + .env?.VITE_FLOW; return (
@@ -69,11 +70,13 @@ const TabBar = ({ }) => (