Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .cspell/custom-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ emvco
endlocal
envoyproxy
esac
fastmcp
felixge
Fiuu
fontawesome
Expand All @@ -66,6 +67,7 @@ gradlew
Gravitee
groupcache
gson
gwei
Hashkey
honnef
hprof
Expand All @@ -83,6 +85,7 @@ jetbrains
Jetpack
jvmargs
Kaia
keccak
keepattributes
keepclassmembers
Klarna
Expand All @@ -93,6 +96,7 @@ ktor
Ktor
KXMYBJWNQ
Lazada
levelname
libpeerconnection
Lightspark
linenums
Expand Down Expand Up @@ -149,6 +153,8 @@ ropeproject
RPCURL
Rulebook
screenreaders
sdjwt
sepolia
setlocal
sharedpref
Shopcider
Expand All @@ -165,10 +171,12 @@ stablecoins
stdr
stretchr
superfences
toggleable
Truelayer
Trulioo
udpa
unmarshal
usdc
viewmodel
vulnz
Wallex
Expand Down
11 changes: 9 additions & 2 deletions .github/workflows/linter.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,25 @@ on:
pull_request:
branches: [main]

permissions:
contents: read
statuses: write
pull-requests: write

jobs:
build:
name: Lint Code Base
runs-on: ubuntu-latest

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 }}
Expand All @@ -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
Expand Down
65 changes: 36 additions & 29 deletions code/samples/python/src/roles/x402_psp_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
15 changes: 15 additions & 0 deletions code/samples/python/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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))
Original file line number Diff line number Diff line change
@@ -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'
Loading