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/.github/workflows/linter.yaml b/.github/workflows/linter.yaml index fea1b9c1..663a5b19 100644 --- a/.github/workflows/linter.yaml +++ b/.github/workflows/linter.yaml @@ -4,6 +4,11 @@ on: pull_request: branches: [main] +permissions: + contents: read + statuses: write + pull-requests: write + jobs: build: name: Lint Code Base @@ -11,12 +16,13 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v5 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: fetch-depth: 0 + persist-credentials: false - name: Lint Code Base - uses: super-linter/super-linter/slim@v8 + uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0 env: DEFAULT_BRANCH: main GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -25,6 +31,7 @@ jobs: VALIDATE_ALL_CODEBASE: false FILTER_REGEX_EXCLUDE: "^(\\.github/|\\.vscode/|code/samples/).*|CODE_OF_CONDUCT.md|CHANGELOG.md" VALIDATE_BIOME_FORMAT: false + VALIDATE_BIOME_LINT: false VALIDATE_PYTHON_BLACK: false VALIDATE_PYTHON_FLAKE8: false VALIDATE_PYTHON_ISORT: false 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..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,36 +113,43 @@ def settle_payment( agent_provider_pub = JWK.from_json( 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" - ) + 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: + return { + "error": "agent_provider_key_missing", + "message": ( + "Agent-provider public key not found; cannot verify payment" + " mandate" + ), + } - 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)} + 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..52d2b890 --- /dev/null +++ b/code/samples/python/tests/roles/x402_psp_mcp/settle_payment_failclosed_tests.py @@ -0,0 +1,81 @@ +"""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. 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 + + +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' + + +@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'