theprotocol-sdk 0.2.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/PKG-INFO +41 -3
  2. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/README.md +38 -0
  3. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/pyproject.toml +3 -3
  4. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/__init__.py +1 -1
  5. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/agent/router.py +80 -2
  6. theprotocol_sdk-0.4.0/src/theprotocol/payment/__init__.py +33 -0
  7. theprotocol_sdk-0.4.0/src/theprotocol/payment/authenticator.py +410 -0
  8. theprotocol_sdk-0.4.0/src/theprotocol/payment/client.py +107 -0
  9. theprotocol_sdk-0.4.0/src/theprotocol/payment/mtls_client.py +139 -0
  10. theprotocol_sdk-0.4.0/src/theprotocol/payment/verifier.py +173 -0
  11. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/.gitignore +0 -0
  12. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/LICENSE +0 -0
  13. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/examples/simple_agent.py +0 -0
  14. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/agent/__init__.py +0 -0
  15. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/agent/base.py +0 -0
  16. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/agent/decorators.py +0 -0
  17. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/agent/task_store.py +0 -0
  18. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/__init__.py +0 -0
  19. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/acp.py +0 -0
  20. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/anp/__init__.py +0 -0
  21. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/anp/auth.py +0 -0
  22. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/anp/bridge.py +0 -0
  23. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/anp/did_wba.py +0 -0
  24. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/anp/translation.py +0 -0
  25. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/base.py +0 -0
  26. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/google_a2a.py +0 -0
  27. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/mcp.py +0 -0
  28. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/client/__init__.py +0 -0
  29. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/client/a2a_client.py +0 -0
  30. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/client/credentials.py +0 -0
  31. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/exceptions.py +0 -0
  32. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/models/__init__.py +0 -0
  33. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/models/a2a_protocol.py +0 -0
  34. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/models/agent_card.py +0 -0
  35. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/models/v1_compat.py +0 -0
  36. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/tests/test_agent.py +0 -0
  37. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/tests/test_bridge_acp.py +0 -0
  38. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/tests/test_bridge_anp.py +0 -0
  39. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/tests/test_bridge_google_a2a.py +0 -0
  40. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/tests/test_bridge_mcp.py +0 -0
  41. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/tests/test_credentials.py +0 -0
  42. {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/tests/test_models.py +0 -0
@@ -1,14 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: theprotocol-sdk
3
- Version: 0.2.0
4
- Summary: TheProtocol SDK — Build and call A2A agents. Protocol bridges for ACP, ADK, and more.
3
+ Version: 0.4.0
4
+ Summary: A2A v1.0 SDK — Build sovereign AI agents with DID identity. Protocol bridges for Google A2A, ANP, MCP. Works with Vertex AI, AWS AgentCore, LangGraph, CrewAI.
5
5
  Project-URL: Homepage, https://theprotocol.cloud
6
6
  Project-URL: Documentation, https://api.theprotocol.cloud/docs
7
7
  Project-URL: Repository, https://github.com/theprotocol/theprotocol-sdk
8
8
  Author-email: TheProtocol <sdk@theprotocol.cloud>
9
9
  License: Apache-2.0
10
10
  License-File: LICENSE
11
- Keywords: a2a,agents,ai,mcp,protocol,theprotocol
11
+ Keywords: a2a,a2a-protocol,agent-to-agent,agents,ai-agents,crewai,did,fastapi,federation,json-rpc,langchain,mcp,sovereign,theprotocol,vertex-ai
12
12
  Classifier: Development Status :: 3 - Alpha
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: License :: OSI Approved :: Apache Software License
@@ -127,6 +127,44 @@ Any platform that speaks A2A v1.0 can call your agent directly:
127
127
 
128
128
  No additional bridges needed. The SDK's JSON-RPC endpoint is the universal interface.
129
129
 
130
+ ## Payment & mTLS Authentication
131
+
132
+ Enforce payment on your agent's A2A endpoints and authenticate callers via SPIFFE mTLS:
133
+
134
+ ```python
135
+ # Agent side — require payment tokens on all A2A calls
136
+ from theprotocol.payment import PaymentVerifier
137
+ from theprotocol.agent import create_a2a_router
138
+ from fastapi import Depends
139
+
140
+ verifier = PaymentVerifier(
141
+ registry_url="https://api.theprotocol.cloud",
142
+ agent_did="did:theprotocol:my-agent",
143
+ )
144
+ router = create_a2a_router(my_agent, dependencies=[Depends(verifier)])
145
+ ```
146
+
147
+ ```python
148
+ # Caller side — acquire payment token before calling an agent
149
+ from theprotocol.payment import PaymentClient
150
+
151
+ client = PaymentClient("https://api.theprotocol.cloud")
152
+ token = await client.get_token(agent_jwt, target_did="...", amount="0.5")
153
+ headers = {"X-Payment-Token": token}
154
+ ```
155
+
156
+ ```python
157
+ # mTLS — authenticate agent-to-agent calls with SPIFFE SVIDs
158
+ from theprotocol.payment import A2AAuthenticator, MtlsAgentClient
159
+
160
+ # Verify incoming mTLS + payment tokens (hybrid auth)
161
+ auth = A2AAuthenticator(registry_url="https://api.theprotocol.cloud")
162
+
163
+ # Make outbound mTLS calls using your agent's SVID
164
+ client = MtlsAgentClient(cert_dir="/certs")
165
+ result = await client.call(target_url, payload)
166
+ ```
167
+
130
168
  ## MCP Tools
131
169
 
132
170
  For governance, staking, transfers, and discovery — use [MCP tools](https://api.theprotocol.cloud/mcp/sse) (19 tools via Claude Desktop or any MCP client).
@@ -86,6 +86,44 @@ Any platform that speaks A2A v1.0 can call your agent directly:
86
86
 
87
87
  No additional bridges needed. The SDK's JSON-RPC endpoint is the universal interface.
88
88
 
89
+ ## Payment & mTLS Authentication
90
+
91
+ Enforce payment on your agent's A2A endpoints and authenticate callers via SPIFFE mTLS:
92
+
93
+ ```python
94
+ # Agent side — require payment tokens on all A2A calls
95
+ from theprotocol.payment import PaymentVerifier
96
+ from theprotocol.agent import create_a2a_router
97
+ from fastapi import Depends
98
+
99
+ verifier = PaymentVerifier(
100
+ registry_url="https://api.theprotocol.cloud",
101
+ agent_did="did:theprotocol:my-agent",
102
+ )
103
+ router = create_a2a_router(my_agent, dependencies=[Depends(verifier)])
104
+ ```
105
+
106
+ ```python
107
+ # Caller side — acquire payment token before calling an agent
108
+ from theprotocol.payment import PaymentClient
109
+
110
+ client = PaymentClient("https://api.theprotocol.cloud")
111
+ token = await client.get_token(agent_jwt, target_did="...", amount="0.5")
112
+ headers = {"X-Payment-Token": token}
113
+ ```
114
+
115
+ ```python
116
+ # mTLS — authenticate agent-to-agent calls with SPIFFE SVIDs
117
+ from theprotocol.payment import A2AAuthenticator, MtlsAgentClient
118
+
119
+ # Verify incoming mTLS + payment tokens (hybrid auth)
120
+ auth = A2AAuthenticator(registry_url="https://api.theprotocol.cloud")
121
+
122
+ # Make outbound mTLS calls using your agent's SVID
123
+ client = MtlsAgentClient(cert_dir="/certs")
124
+ result = await client.call(target_url, payload)
125
+ ```
126
+
89
127
  ## MCP Tools
90
128
 
91
129
  For governance, staking, transfers, and discovery — use [MCP tools](https://api.theprotocol.cloud/mcp/sse) (19 tools via Claude Desktop or any MCP client).
@@ -4,15 +4,15 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "theprotocol-sdk"
7
- version = "0.2.0"
8
- description = "TheProtocol SDK — Build and call A2A agents. Protocol bridges for ACP, ADK, and more."
7
+ version = "0.4.0"
8
+ description = "A2A v1.0 SDK — Build sovereign AI agents with DID identity. Protocol bridges for Google A2A, ANP, MCP. Works with Vertex AI, AWS AgentCore, LangGraph, CrewAI."
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
11
11
  requires-python = ">=3.10"
12
12
  authors = [
13
13
  {name = "TheProtocol", email = "sdk@theprotocol.cloud"},
14
14
  ]
15
- keywords = ["agents", "a2a", "protocol", "ai", "mcp", "theprotocol"]
15
+ keywords = ["agents", "a2a", "a2a-protocol", "agent-to-agent", "ai-agents", "mcp", "did", "sovereign", "federation", "vertex-ai", "langchain", "crewai", "fastapi", "json-rpc", "theprotocol"]
16
16
  classifiers = [
17
17
  "Development Status :: 3 - Alpha",
18
18
  "Intended Audience :: Developers",
@@ -7,4 +7,4 @@ Usage:
7
7
  from theprotocol.client import A2AClient, KeyManager
8
8
  """
9
9
 
10
- __version__ = "0.1.0"
10
+ __version__ = "0.4.0"
@@ -1,9 +1,21 @@
1
1
  """FastAPI router factory for A2A agents.
2
- Accepts both A2A v1.0 and v0.3 wire formats. Responds in v1.0."""
2
+ Accepts both A2A v1.0 and v0.3 wire formats. Responds in v1.0.
3
+
4
+ Payment enforcement (f045):
5
+ When REGISTRY_URL env var is set, a PaymentVerifier is automatically
6
+ injected into the router dependencies. This means every SDK-based agent
7
+ gets payment verification by default when deployed to the network.
8
+
9
+ - REGISTRY_URL set → payment verification ON (required=True by default)
10
+ - PAYMENT_REQUIRED=false → verification runs but doesn't reject (log only)
11
+ - REGISTRY_URL not set → no verification (dev/testing mode)
12
+ - Price=0 agents: caller still gets a 0 AVT token (proves identity), no funds move
13
+ """
3
14
 
4
15
  import json
5
16
  import inspect
6
17
  import logging
18
+ import os
7
19
  from typing import Any, Dict, Optional, Union, List, Callable, AsyncGenerator
8
20
 
9
21
  import pydantic
@@ -87,10 +99,19 @@ def create_a2a_router(
87
99
  tags: Optional[List[str]] = None,
88
100
  task_store: Optional[BaseTaskStore] = None,
89
101
  dependencies: Optional[list] = None,
102
+ payment_required: Optional[bool] = None,
90
103
  ) -> APIRouter:
91
104
  """
92
105
  Create a FastAPI router that exposes an A2A agent via JSON-RPC 2.0.
93
106
 
107
+ Payment enforcement is automatic when REGISTRY_URL env var is set:
108
+ - REGISTRY_URL → PaymentVerifier injected (agents must receive payment tokens)
109
+ - AGENT_DID → identifies this agent for token target validation
110
+ - PAYMENT_REQUIRED=false → verify but don't reject (log only mode)
111
+ - TRUSTED_REGISTRIES → comma-separated URLs for cross-registry tokens
112
+
113
+ Override with payment_required=False to explicitly disable.
114
+
94
115
  Example::
95
116
 
96
117
  from theprotocol.agent import BaseA2AAgent, create_a2a_router
@@ -102,7 +123,11 @@ def create_a2a_router(
102
123
  ...
103
124
 
104
125
  app = FastAPI()
126
+ # Payment verification is automatic if REGISTRY_URL is set
105
127
  app.include_router(create_a2a_router(MyAgent()))
128
+
129
+ # Explicitly disable payment verification
130
+ app.include_router(create_a2a_router(MyAgent(), payment_required=False))
106
131
  """
107
132
  if tags is None:
108
133
  tags = ["A2A Protocol"]
@@ -110,7 +135,60 @@ def create_a2a_router(
110
135
  task_store = InMemoryTaskStore()
111
136
  store = task_store
112
137
 
113
- router = APIRouter(prefix=prefix, tags=tags, dependencies=dependencies or [])
138
+ # Build dependencies list
139
+ all_deps = list(dependencies or [])
140
+
141
+ # Auto-inject PaymentVerifier if REGISTRY_URL is configured
142
+ _registry_url = os.environ.get("REGISTRY_URL", "")
143
+ _agent_did = os.environ.get("AGENT_DID", "")
144
+ _pay_required = payment_required
145
+ if _pay_required is None:
146
+ _pay_required = os.environ.get("PAYMENT_REQUIRED", "true").lower() != "false"
147
+
148
+ if _registry_url and _agent_did and _pay_required is not False:
149
+ try:
150
+ from ..payment.verifier import PaymentVerifier
151
+ _trusted_raw = os.environ.get("TRUSTED_REGISTRIES", "")
152
+ _trusted = [u.strip() for u in _trusted_raw.split(",") if u.strip()] if _trusted_raw else None
153
+ _verifier = PaymentVerifier(
154
+ registry_url=_registry_url,
155
+ agent_did=_agent_did,
156
+ required=bool(_pay_required),
157
+ trusted_registries=_trusted,
158
+ )
159
+ all_deps.append(Depends(_verifier))
160
+ logger.info(
161
+ f"[A2A Router] PaymentVerifier auto-injected "
162
+ f"(registry={_registry_url}, did={_agent_did[:30]}, required={_pay_required})"
163
+ )
164
+ except ImportError:
165
+ logger.warning("[A2A Router] PaymentVerifier import failed — payment module not installed")
166
+ except Exception as e:
167
+ logger.warning(f"[A2A Router] PaymentVerifier setup failed: {e}")
168
+ elif _registry_url and not _agent_did:
169
+ logger.warning("[A2A Router] REGISTRY_URL set but AGENT_DID missing — payment verification disabled")
170
+
171
+ # f052: Auto-inject A2AAuthenticator if SPIRE socket is available (IRONHAND)
172
+ _spiffe_socket = os.environ.get("SPIFFE_ENDPOINT_SOCKET", "")
173
+ _enable_mtls = os.environ.get("ENABLE_MTLS", "").lower() == "true"
174
+ if _spiffe_socket and _enable_mtls:
175
+ try:
176
+ from ..payment.authenticator import A2AAuthenticator
177
+ _authenticator = A2AAuthenticator(
178
+ payment_verifier=_verifier if "_verifier" in dir() else None,
179
+ required=False,
180
+ )
181
+ all_deps.append(Depends(_authenticator))
182
+ logger.info(
183
+ f"[A2A Router] A2AAuthenticator auto-injected (mTLS enabled, "
184
+ f"socket={_spiffe_socket[:40]})"
185
+ )
186
+ except ImportError:
187
+ logger.warning("[A2A Router] A2AAuthenticator import failed — mTLS module not installed")
188
+ except Exception as e:
189
+ logger.warning(f"[A2A Router] A2AAuthenticator setup failed: {e}")
190
+
191
+ router = APIRouter(prefix=prefix, tags=tags, dependencies=all_deps)
114
192
 
115
193
  # Discover @a2a_method decorated handlers
116
194
  decorated: Dict[str, Callable] = {}
@@ -0,0 +1,33 @@
1
+ """
2
+ TheProtocol A2A Payment Module — f045
3
+
4
+ Provides payment verification middleware for agents and a client helper
5
+ for acquiring payment tokens before A2A calls.
6
+
7
+ Usage (agent-side — enforce payment on all A2A calls):
8
+
9
+ from theprotocol.payment import PaymentVerifier
10
+ from theprotocol.agent import create_a2a_router
11
+ from fastapi import Depends
12
+
13
+ verifier = PaymentVerifier(
14
+ registry_url="https://api.theprotocol.cloud",
15
+ agent_did="did:theprotocol:my-agent",
16
+ )
17
+ router = create_a2a_router(my_agent, dependencies=[Depends(verifier)])
18
+
19
+ Usage (caller-side — get a payment token before calling an agent):
20
+
21
+ from theprotocol.payment import PaymentClient
22
+
23
+ client = PaymentClient("https://api.theprotocol.cloud")
24
+ token = await client.get_token(agent_jwt, target_did="...", amount="0.5")
25
+ # Pass token as X-Payment-Token header in A2A request
26
+ """
27
+
28
+ from .verifier import PaymentVerifier
29
+ from .client import PaymentClient
30
+ from .authenticator import A2AAuthenticator
31
+ from .mtls_client import MtlsAgentClient
32
+
33
+ __all__ = ["PaymentVerifier", "PaymentClient", "A2AAuthenticator", "MtlsAgentClient"]
@@ -0,0 +1,410 @@
1
+ """
2
+ f052: A2AAuthenticator — Hybrid mTLS + Payment Token Authentication (IRONHAND)
3
+
4
+ FastAPI Depends() middleware that authenticates A2A callers via:
5
+ 1. mTLS client certificate (SPIFFE SVID) — extracted from X-SSL-Client-Cert header
6
+ (behind nginx) or TLS connection info (direct connection)
7
+ 2. Payment token (X-Payment-Token header) — delegated to PaymentVerifier
8
+
9
+ Both can coexist on the same request (belt-and-suspenders).
10
+
11
+ Revocation enforcement:
12
+ - Maintains a shared blocklist of revoked SPIFFE IDs
13
+ - Polls GET /api/v1/agent/revoked-identities every 60 seconds (fallback)
14
+ - Rejects any mTLS call from a blocklisted SPIFFE ID immediately
15
+
16
+ Usage:
17
+ from theprotocol.payment.authenticator import A2AAuthenticator
18
+ authenticator = A2AAuthenticator(payment_verifier=my_verifier)
19
+ @app.post("/a2a", dependencies=[Depends(authenticator)])
20
+ """
21
+ import asyncio
22
+ import logging
23
+ import os
24
+ import threading
25
+ import time
26
+ import urllib.parse
27
+ from typing import Any, ClassVar, Dict, Optional, Set
28
+
29
+ try:
30
+ from starlette.requests import Request as StarletteRequest
31
+ except ImportError:
32
+ StarletteRequest = Any # type: ignore
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # ── Shared revocation blocklist (class-level, all instances) ──────────
37
+ _revoked_spiffe_ids: Set[str] = set()
38
+ _blocklist_poller_started = False
39
+ _blocklist_ws_started = False
40
+ _blocklist_lock = threading.Lock()
41
+
42
+
43
+ def _extract_spiffe_id_from_pem(pem_str: str) -> Optional[str]:
44
+ """Parse a PEM certificate and extract the SPIFFE ID from the URI SAN."""
45
+ try:
46
+ from cryptography import x509
47
+ from cryptography.x509.oid import ExtensionOID
48
+
49
+ cert = x509.load_pem_x509_certificate(pem_str.encode("utf-8"))
50
+ try:
51
+ san_ext = cert.extensions.get_extension_for_oid(
52
+ ExtensionOID.SUBJECT_ALTERNATIVE_NAME
53
+ )
54
+ for name in san_ext.value.get_values_for_type(
55
+ x509.UniformResourceIdentifier
56
+ ):
57
+ if name.startswith("spiffe://"):
58
+ return name
59
+ except x509.ExtensionNotFound:
60
+ pass
61
+ except ImportError:
62
+ logger.warning(
63
+ "[A2AAuthenticator] cryptography library not installed — "
64
+ "cannot parse client certs"
65
+ )
66
+ except Exception as e:
67
+ logger.debug(f"[A2AAuthenticator] Failed to parse PEM cert: {e}")
68
+ return None
69
+
70
+
71
+ def _did_from_spiffe_id(spiffe_id: str, trust_domain: str) -> Optional[str]:
72
+ """Extract agent DID from SPIFFE ID path.
73
+
74
+ spiffe://agentvault.com/agent/did-theprotocol-4c78...
75
+ → did:theprotocol:4c78...
76
+ """
77
+ prefix = f"spiffe://{trust_domain}/agent/"
78
+ if not spiffe_id.startswith(prefix):
79
+ return None
80
+ sanitized = spiffe_id[len(prefix):]
81
+ parts = sanitized.split("-", 2)
82
+ if len(parts) >= 3:
83
+ return f"{parts[0]}:{parts[1]}:{parts[2]}"
84
+ return sanitized.replace("-", ":")
85
+
86
+
87
+ def _start_blocklist_poller(registry_url: str, poll_interval: int = 60):
88
+ """Start a background thread that polls the revoked-identities endpoint.
89
+
90
+ This is the fallback mechanism. The primary is EventStore WebSocket (future).
91
+ Runs as a daemon thread — dies with the process.
92
+ """
93
+ global _blocklist_poller_started
94
+ with _blocklist_lock:
95
+ if _blocklist_poller_started:
96
+ return
97
+ _blocklist_poller_started = True
98
+
99
+ def _poll():
100
+ import urllib.request
101
+ import json
102
+
103
+ url = f"{registry_url}/api/v1/agent/revoked-identities"
104
+ while True:
105
+ try:
106
+ time.sleep(poll_interval)
107
+ req = urllib.request.Request(url, method="GET")
108
+ with urllib.request.urlopen(req, timeout=10) as resp:
109
+ data = json.loads(resp.read())
110
+ revoked = data.get("revoked", [])
111
+ new_ids = {r["spiffe_id"] for r in revoked if r.get("spiffe_id")}
112
+ if new_ids != _revoked_spiffe_ids:
113
+ added = new_ids - _revoked_spiffe_ids
114
+ removed = _revoked_spiffe_ids - new_ids
115
+ _revoked_spiffe_ids.clear()
116
+ _revoked_spiffe_ids.update(new_ids)
117
+ if added:
118
+ logger.info(
119
+ f"[A2AAuthenticator] Blocklist updated: "
120
+ f"+{len(added)} revoked, -{len(removed)} restored, "
121
+ f"total={len(_revoked_spiffe_ids)}"
122
+ )
123
+ except Exception as e:
124
+ logger.debug(f"[A2AAuthenticator] Blocklist poll failed: {e}")
125
+
126
+ t = threading.Thread(target=_poll, daemon=True, name="ironhand-blocklist-poller")
127
+ t.start()
128
+ logger.info(
129
+ f"[A2AAuthenticator] Blocklist poller started "
130
+ f"(registry={registry_url}, interval={poll_interval}s)"
131
+ )
132
+
133
+
134
+ def _start_ws_listener(event_store_ws_url: str):
135
+ """Start a background thread that listens to EventStore WebSocket for
136
+ real-time revocation/enrollment events. Sub-1s blocklist updates.
137
+
138
+ Subscribes to:
139
+ - events:AgentMtlsRevoked → add to blocklist
140
+ - events:AgentMtlsEnrolled → remove from blocklist
141
+
142
+ Auto-reconnects with exponential backoff on disconnect.
143
+ """
144
+ global _blocklist_ws_started
145
+ with _blocklist_lock:
146
+ if _blocklist_ws_started:
147
+ return
148
+ _blocklist_ws_started = True
149
+
150
+ def _ws_loop():
151
+ import json
152
+
153
+ ws_url = f"{event_store_ws_url.rstrip('/')}/ws/events"
154
+ backoff = 1
155
+
156
+ while True:
157
+ try:
158
+ import websockets.sync.client as ws_sync
159
+ logger.info(f"[A2AAuthenticator] WS connecting to {ws_url}")
160
+
161
+ with ws_sync.connect(ws_url, close_timeout=5) as ws:
162
+ # Read welcome message
163
+ welcome = ws.recv(timeout=10)
164
+ logger.info(f"[A2AAuthenticator] WS connected to EventStore")
165
+
166
+ # Subscribe to revocation + enrollment channels
167
+ ws.send(json.dumps({"action": "subscribe", "channel": "events:AgentMtlsRevoked"}))
168
+ ws.recv(timeout=5) # confirmation
169
+ ws.send(json.dumps({"action": "subscribe", "channel": "events:AgentMtlsEnrolled"}))
170
+ ws.recv(timeout=5) # confirmation
171
+
172
+ logger.info(
173
+ "[A2AAuthenticator] WS subscribed to AgentMtlsRevoked + AgentMtlsEnrolled"
174
+ )
175
+ backoff = 1 # reset on successful connection
176
+
177
+ # Listen for events
178
+ while True:
179
+ raw = ws.recv(timeout=300) # 5min keepalive timeout
180
+ try:
181
+ msg = json.loads(raw)
182
+ except (json.JSONDecodeError, TypeError):
183
+ continue
184
+
185
+ if msg.get("type") != "event":
186
+ continue
187
+
188
+ event = msg.get("event", {})
189
+ event_type = event.get("event_type", "")
190
+ event_data = event.get("data", {})
191
+ spiffe_id = event_data.get("spiffe_id", "")
192
+
193
+ if not spiffe_id:
194
+ continue
195
+
196
+ if event_type == "AgentMtlsRevoked":
197
+ _revoked_spiffe_ids.add(spiffe_id)
198
+ logger.info(
199
+ f"[A2AAuthenticator] WS REVOKED: {spiffe_id} "
200
+ f"(blocklist={len(_revoked_spiffe_ids)})"
201
+ )
202
+ elif event_type == "AgentMtlsEnrolled":
203
+ _revoked_spiffe_ids.discard(spiffe_id)
204
+ logger.info(
205
+ f"[A2AAuthenticator] WS ENROLLED: {spiffe_id} removed from blocklist "
206
+ f"(blocklist={len(_revoked_spiffe_ids)})"
207
+ )
208
+
209
+ except ImportError:
210
+ logger.warning(
211
+ "[A2AAuthenticator] websockets library not installed — "
212
+ "WS listener disabled, using polling only"
213
+ )
214
+ return # exit thread permanently
215
+ except Exception as e:
216
+ logger.warning(
217
+ f"[A2AAuthenticator] WS disconnected: {e}. "
218
+ f"Reconnecting in {backoff}s..."
219
+ )
220
+ time.sleep(backoff)
221
+ backoff = min(backoff * 2, 30)
222
+
223
+ t = threading.Thread(target=_ws_loop, daemon=True, name="ironhand-ws-listener")
224
+ t.start()
225
+ logger.info(f"[A2AAuthenticator] WS listener started (url={event_store_ws_url})")
226
+
227
+
228
+ class A2AAuthenticator:
229
+ """Hybrid authenticator: mTLS identity + payment token.
230
+
231
+ Tries mTLS first (X-SSL-Client-Cert header from nginx, or raw TLS info).
232
+ Falls back to PaymentVerifier for payment token auth.
233
+ Both can coexist on the same request (belt-and-suspenders).
234
+
235
+ Revocation: checks caller's SPIFFE ID against a shared blocklist updated via:
236
+ 1. EventStore WebSocket (sub-1s, if EVENT_STORE_WS_URL configured)
237
+ 2. Polling GET /agent/revoked-identities (60s fallback)
238
+ A revoked identity gets an immediate 403, even if the SVID cert is still
239
+ cryptographically valid.
240
+
241
+ Args:
242
+ payment_verifier: Optional PaymentVerifier instance for fallback.
243
+ trust_domain: Expected SPIFFE trust domain (default: agentvault.com).
244
+ required: If True, raises 401 when neither mTLS nor payment succeeds.
245
+ registry_url: Registry URL for blocklist polling (default: REGISTRY_URL env).
246
+ poll_interval: Blocklist poll interval in seconds (default: 60).
247
+ event_store_ws_url: EventStore WebSocket URL for real-time revocation (default: EVENT_STORE_WS_URL env).
248
+ """
249
+
250
+ def __init__(
251
+ self,
252
+ payment_verifier: Any = None,
253
+ trust_domain: str = "agentvault.com",
254
+ required: bool = False,
255
+ registry_url: str = "",
256
+ poll_interval: int = 60,
257
+ event_store_ws_url: str = "",
258
+ ):
259
+ self.payment_verifier = payment_verifier
260
+ self.required = required
261
+ # Support comma-separated trust domains for cross-frame federation
262
+ if "," in trust_domain:
263
+ self.trusted_domains = [d.strip() for d in trust_domain.split(",") if d.strip()]
264
+ else:
265
+ self.trusted_domains = [trust_domain]
266
+ self.trust_domain = self.trusted_domains[0] # primary domain (backward compat)
267
+
268
+ # Start blocklist poller if registry URL is available
269
+ reg_url = registry_url or os.environ.get("REGISTRY_URL", "")
270
+ if reg_url:
271
+ _start_blocklist_poller(reg_url, poll_interval)
272
+
273
+ # Start EventStore WebSocket listener for sub-1s revocation
274
+ es_ws_url = event_store_ws_url or os.environ.get("EVENT_STORE_WS_URL", "")
275
+ if es_ws_url:
276
+ _start_ws_listener(es_ws_url)
277
+
278
+ async def __call__(self, request: StarletteRequest) -> Dict[str, Any]:
279
+ """FastAPI Depends() entry point."""
280
+ result: Dict[str, Any] = {
281
+ "verified": False,
282
+ "method": None,
283
+ "agent_did": None,
284
+ "spiffe_id": None,
285
+ }
286
+
287
+ # --- Strategy 1: mTLS client certificate ---
288
+ spiffe_id = self._try_extract_spiffe_id(request)
289
+ if spiffe_id:
290
+ # CHECK BLOCKLIST FIRST — immediate revocation enforcement
291
+ if spiffe_id in _revoked_spiffe_ids:
292
+ logger.warning(
293
+ f"[A2AAuthenticator] BLOCKED revoked identity: {spiffe_id}"
294
+ )
295
+ from fastapi import HTTPException
296
+ raise HTTPException(
297
+ status_code=403,
298
+ detail=f"Agent identity revoked: {spiffe_id}",
299
+ )
300
+
301
+ # Validate trust domain (supports cross-frame federation)
302
+ matched_domain = None
303
+ for td in self.trusted_domains:
304
+ if spiffe_id.startswith(f"spiffe://{td}/agent/"):
305
+ matched_domain = td
306
+ break
307
+
308
+ if matched_domain:
309
+ agent_did = _did_from_spiffe_id(spiffe_id, matched_domain)
310
+ result = {
311
+ "verified": True,
312
+ "method": "mtls",
313
+ "agent_did": agent_did,
314
+ "spiffe_id": spiffe_id,
315
+ }
316
+ logger.debug(
317
+ f"[A2AAuthenticator] mTLS verified: {spiffe_id} → {agent_did}"
318
+ )
319
+ if hasattr(request, "state"):
320
+ request.state.mtls_auth = result
321
+ return result
322
+ else:
323
+ logger.warning(
324
+ f"[A2AAuthenticator] SPIFFE ID from untrusted domain: {spiffe_id}"
325
+ )
326
+
327
+ # --- Strategy 2: Payment token (fallback) ---
328
+ if self.payment_verifier is not None:
329
+ try:
330
+ payment_result = await self.payment_verifier(request)
331
+ if payment_result and payment_result.get("verified"):
332
+ result = {
333
+ "verified": True,
334
+ "method": "payment",
335
+ "agent_did": payment_result.get("caller_did"),
336
+ "spiffe_id": None,
337
+ "payment_info": payment_result,
338
+ }
339
+ if hasattr(request, "state"):
340
+ request.state.mtls_auth = result
341
+ return result
342
+ except Exception as e:
343
+ if self.required:
344
+ raise
345
+ logger.debug(f"[A2AAuthenticator] Payment verification failed: {e}")
346
+
347
+ # --- Neither worked ---
348
+ if self.required:
349
+ from fastapi import HTTPException
350
+ raise HTTPException(
351
+ status_code=401,
352
+ detail="Authentication required: provide mTLS client cert or payment token",
353
+ )
354
+
355
+ if hasattr(request, "state"):
356
+ request.state.mtls_auth = result
357
+ return result
358
+
359
+ def _try_extract_spiffe_id(self, request: Any) -> Optional[str]:
360
+ """Try to extract SPIFFE ID from the request."""
361
+ # Method 1: nginx forwards URL-encoded PEM cert
362
+ cert_header = None
363
+ if hasattr(request, "headers"):
364
+ cert_header = request.headers.get("X-SSL-Client-Cert")
365
+ if not cert_header:
366
+ cert_header = request.headers.get("x-ssl-client-cert")
367
+
368
+ if cert_header:
369
+ pem_str = urllib.parse.unquote(cert_header)
370
+ spiffe_id = _extract_spiffe_id_from_pem(pem_str)
371
+ if spiffe_id:
372
+ return spiffe_id
373
+
374
+ # Method 2: Direct SPIFFE ID header
375
+ if hasattr(request, "headers"):
376
+ direct_id = request.headers.get("X-SPIFFE-ID")
377
+ if direct_id and any(direct_id.startswith(f"spiffe://{d}/") for d in self.trusted_domains):
378
+ return direct_id
379
+
380
+ # Method 3: ASGI TLS scope
381
+ if hasattr(request, "scope"):
382
+ tls_info = request.scope.get("extensions", {}).get("tls")
383
+ if tls_info:
384
+ client_certs = tls_info.get("client_cert_chain", [])
385
+ if client_certs:
386
+ spiffe_id = _extract_spiffe_id_from_pem(client_certs[0])
387
+ if spiffe_id:
388
+ return spiffe_id
389
+
390
+ return None
391
+
392
+ @staticmethod
393
+ def add_to_blocklist(spiffe_id: str):
394
+ """Manually add a SPIFFE ID to the revocation blocklist.
395
+
396
+ Used by EventStore WebSocket listener (future) or manual override.
397
+ """
398
+ _revoked_spiffe_ids.add(spiffe_id)
399
+ logger.info(f"[A2AAuthenticator] Added to blocklist: {spiffe_id}")
400
+
401
+ @staticmethod
402
+ def remove_from_blocklist(spiffe_id: str):
403
+ """Remove a SPIFFE ID from the blocklist (re-enrollment)."""
404
+ _revoked_spiffe_ids.discard(spiffe_id)
405
+ logger.info(f"[A2AAuthenticator] Removed from blocklist: {spiffe_id}")
406
+
407
+ @staticmethod
408
+ def get_blocklist() -> Set[str]:
409
+ """Return current blocklist (for monitoring/debugging)."""
410
+ return set(_revoked_spiffe_ids)
@@ -0,0 +1,107 @@
1
+ """
2
+ A2A Payment Client — helper for acquiring and settling payment tokens.
3
+
4
+ Used by orchestrators and agents that call other agents.
5
+
6
+ client = PaymentClient("https://api.theprotocol.cloud")
7
+ token = await client.get_token(agent_jwt, "did:theprotocol:weather", "0.5")
8
+ # ... call agent A2A with X-Payment-Token header ...
9
+ await client.settle(agent_jwt, token, task_id="weather-123")
10
+ """
11
+ import logging
12
+ from typing import Optional, Dict
13
+
14
+ import httpx
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class PaymentClient:
20
+ """Client for the registry A2A payment authorization system."""
21
+
22
+ def __init__(self, registry_url: str):
23
+ self.base = registry_url.rstrip("/")
24
+
25
+ def a2a_headers(self, token: str) -> Dict[str, str]:
26
+ """
27
+ Build headers to include in an A2A request.
28
+ Returns both X-Payment-Token and X-Payment-Issuer for cross-registry support.
29
+ """
30
+ return {
31
+ "X-Payment-Token": token,
32
+ "X-Payment-Issuer": self.base,
33
+ }
34
+
35
+ async def get_token(
36
+ self,
37
+ agent_jwt: str,
38
+ target_did: str,
39
+ amount: str,
40
+ contract_id: Optional[int] = None,
41
+ ttl_seconds: int = 900,
42
+ ) -> str:
43
+ """
44
+ Request a payment token from the registry.
45
+
46
+ Returns the plain token string to include as X-Payment-Token header.
47
+ Use a2a_headers(token) to get both X-Payment-Token and X-Payment-Issuer.
48
+ Raises on failure (insufficient balance, target not found, etc.).
49
+ """
50
+ async with httpx.AsyncClient(timeout=10.0) as client:
51
+ resp = await client.post(
52
+ f"{self.base}/api/v1/a2a-payment/authorize",
53
+ json={
54
+ "target_agent_did": target_did,
55
+ "amount": amount,
56
+ "contract_id": contract_id,
57
+ "ttl_seconds": ttl_seconds,
58
+ },
59
+ headers={"Authorization": f"Bearer {agent_jwt}"},
60
+ )
61
+ if resp.status_code == 200:
62
+ data = resp.json()
63
+ logger.info(f"[PaymentClient] Token acquired: {amount} AVT → {target_did}")
64
+ return data["token"]
65
+ error = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {"detail": resp.text}
66
+ detail = error.get("detail", str(error))
67
+ raise RuntimeError(f"Payment authorization failed ({resp.status_code}): {detail}")
68
+
69
+ async def settle(
70
+ self,
71
+ agent_jwt: str,
72
+ token: str,
73
+ task_id: Optional[str] = None,
74
+ success: bool = True,
75
+ ) -> Dict:
76
+ """
77
+ Settle a payment token after task completion.
78
+ Triggers the actual TEG transfer from caller to target.
79
+ """
80
+ async with httpx.AsyncClient(timeout=15.0) as client:
81
+ resp = await client.post(
82
+ f"{self.base}/api/v1/a2a-payment/settle",
83
+ json={
84
+ "token": token,
85
+ "task_id": task_id,
86
+ "success": success,
87
+ },
88
+ headers={"Authorization": f"Bearer {agent_jwt}"},
89
+ )
90
+ if resp.status_code == 200:
91
+ data = resp.json()
92
+ logger.info(f"[PaymentClient] Settled: {data.get('settlement_tx_id', '?')}")
93
+ return data
94
+ error = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {"detail": resp.text}
95
+ raise RuntimeError(f"Settlement failed ({resp.status_code}): {error.get('detail', str(error))}")
96
+
97
+ async def release(self, agent_jwt: str, token: str) -> Dict:
98
+ """Release an unused payment token (cancel)."""
99
+ async with httpx.AsyncClient(timeout=10.0) as client:
100
+ resp = await client.post(
101
+ f"{self.base}/api/v1/a2a-payment/release",
102
+ json={"token": token},
103
+ headers={"Authorization": f"Bearer {agent_jwt}"},
104
+ )
105
+ if resp.status_code == 200:
106
+ return resp.json()
107
+ raise RuntimeError(f"Release failed ({resp.status_code})")
@@ -0,0 +1,139 @@
1
+ """
2
+ f052: MtlsAgentClient — SPIRE SVID-based mTLS Client for Agent-to-Agent Calls (IRONHAND)
3
+
4
+ Wraps httpx.AsyncClient with the agent's SVID certificate for outbound mTLS calls.
5
+ The agent fetches its SVID from SPIRE via the workload API and stores the cert/key/bundle
6
+ to disk. This client reads those files for TLS configuration.
7
+
8
+ Usage:
9
+ client = MtlsAgentClient(cert_dir="/tmp/svid")
10
+ result = await client.call_agent(
11
+ "https://bravo:8152/a2a",
12
+ method="tasks/send",
13
+ params={"message": {"role": "user", "parts": [{"text": "Hello"}]}},
14
+ )
15
+ """
16
+ import json
17
+ import logging
18
+ from typing import Any, Dict, Optional
19
+ from uuid import uuid4
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ try:
24
+ import httpx
25
+
26
+ _HTTPX_AVAILABLE = True
27
+ except ImportError:
28
+ _HTTPX_AVAILABLE = False
29
+
30
+
31
+ def _extract_spiffe_id_from_cert(cert_path: str) -> Optional[str]:
32
+ """Read a PEM certificate file and extract the SPIFFE ID from the URI SAN."""
33
+ try:
34
+ from cryptography import x509
35
+ from cryptography.x509.oid import ExtensionOID
36
+
37
+ with open(cert_path, "rb") as f:
38
+ cert = x509.load_pem_x509_certificate(f.read())
39
+ san_ext = cert.extensions.get_extension_for_oid(
40
+ ExtensionOID.SUBJECT_ALTERNATIVE_NAME
41
+ )
42
+ for name in san_ext.value.get_values_for_type(
43
+ x509.UniformResourceIdentifier
44
+ ):
45
+ if name.startswith("spiffe://"):
46
+ return name
47
+ except Exception as e:
48
+ logger.debug(f"[MtlsAgentClient] Failed to read SPIFFE ID from {cert_path}: {e}")
49
+ return None
50
+
51
+
52
+ class MtlsAgentClient:
53
+ """httpx client with SPIRE SVID for outbound mTLS agent-to-agent calls.
54
+
55
+ Reads certificate, key, and CA bundle from a directory populated by the
56
+ SVID fetcher (svid_fetcher.py) or cert-writer sidecar.
57
+
58
+ Args:
59
+ cert_dir: Directory containing svid.pem, key.pem, bundle.pem.
60
+ timeout: Request timeout in seconds.
61
+ """
62
+
63
+ def __init__(self, cert_dir: str = "/tmp/svid", timeout: float = 30.0):
64
+ if not _HTTPX_AVAILABLE:
65
+ raise ImportError("httpx is required for MtlsAgentClient")
66
+
67
+ self.cert_path = f"{cert_dir}/svid.pem"
68
+ self.key_path = f"{cert_dir}/key.pem"
69
+ self.bundle_path = f"{cert_dir}/bundle.pem"
70
+ self.timeout = timeout
71
+ self._own_spiffe_id: Optional[str] = None
72
+
73
+ @property
74
+ def cert(self) -> tuple:
75
+ """Certificate + key tuple for httpx."""
76
+ return (self.cert_path, self.key_path)
77
+
78
+ def get_own_spiffe_id(self) -> Optional[str]:
79
+ """Read own SPIFFE ID from the SVID certificate SAN."""
80
+ if self._own_spiffe_id is None:
81
+ self._own_spiffe_id = _extract_spiffe_id_from_cert(self.cert_path)
82
+ return self._own_spiffe_id
83
+
84
+ async def call_agent(
85
+ self,
86
+ url: str,
87
+ method: str,
88
+ params: Optional[Dict[str, Any]] = None,
89
+ payment_token: Optional[str] = None,
90
+ request_id: Optional[str] = None,
91
+ ) -> Dict[str, Any]:
92
+ """Send a JSON-RPC 2.0 request to another agent over mTLS.
93
+
94
+ Args:
95
+ url: Target agent's mTLS endpoint (https://...)
96
+ method: JSON-RPC method (e.g., "tasks/send")
97
+ params: Method parameters
98
+ payment_token: Optional payment token (belt-and-suspenders)
99
+ request_id: Optional JSON-RPC request ID
100
+
101
+ Returns:
102
+ Parsed JSON response from the target agent.
103
+ """
104
+ headers: Dict[str, str] = {"Content-Type": "application/json"}
105
+ if payment_token:
106
+ headers["X-Payment-Token"] = payment_token
107
+
108
+ payload = {
109
+ "jsonrpc": "2.0",
110
+ "method": method,
111
+ "params": params or {},
112
+ "id": request_id or str(uuid4()),
113
+ }
114
+
115
+ logger.debug(
116
+ f"[MtlsAgentClient] mTLS call → {url} method={method} "
117
+ f"spiffe_id={self.get_own_spiffe_id()}"
118
+ )
119
+
120
+ async with httpx.AsyncClient(
121
+ cert=self.cert,
122
+ verify=self.bundle_path,
123
+ timeout=self.timeout,
124
+ ) as client:
125
+ response = await client.post(
126
+ url, content=json.dumps(payload), headers=headers
127
+ )
128
+ response.raise_for_status()
129
+ return response.json()
130
+
131
+ async def health_check(self, url: str) -> Dict[str, Any]:
132
+ """Check an agent's health endpoint over mTLS."""
133
+ async with httpx.AsyncClient(
134
+ cert=self.cert,
135
+ verify=self.bundle_path,
136
+ timeout=10.0,
137
+ ) as client:
138
+ response = await client.get(url)
139
+ return response.json()
@@ -0,0 +1,173 @@
1
+ """
2
+ A2A Payment Verifier — FastAPI middleware for agent-side payment enforcement.
3
+
4
+ Agents add this as a dependency to their A2A router. It verifies the
5
+ X-Payment-Token header against the issuing registry before allowing the request.
6
+
7
+ Supports cross-registry verification: if the token was issued by a different
8
+ registry (indicated via X-Payment-Issuer header), the verifier checks the
9
+ issuer against a trusted registries whitelist and verifies with that registry.
10
+
11
+ verifier = PaymentVerifier(
12
+ registry_url="https://api.theprotocol.cloud",
13
+ agent_did="did:theprotocol:my-agent",
14
+ required=True,
15
+ min_amount="0.1",
16
+ trusted_registries=["https://registry-b.theprotocol.cloud"],
17
+ )
18
+ router = create_a2a_router(agent, dependencies=[Depends(verifier)])
19
+ """
20
+ import logging
21
+ import time
22
+ from decimal import Decimal
23
+ from typing import Optional, Dict, Any, List
24
+
25
+ import httpx
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Bounded TTL cache: {cache_key: (result_dict, expire_time)}
30
+ _verify_cache: Dict[str, tuple] = {}
31
+ _CACHE_TTL = 5 # seconds — short to prevent replay of released tokens
32
+ _CACHE_MAX = 200 # max entries — prevents unbounded memory growth
33
+
34
+
35
+ class PaymentVerifier:
36
+ """
37
+ FastAPI dependency that verifies A2A payment tokens.
38
+
39
+ On each incoming A2A request:
40
+ 1. Extracts X-Payment-Token and optional X-Payment-Issuer headers
41
+ 2. Determines which registry to verify against (local or issuer)
42
+ 3. Calls POST /api/v1/a2a-payment/verify on the issuing registry
43
+ 4. Validates response (valid, target matches, not expired)
44
+ 5. Stores payment info in request.state.payment
45
+
46
+ Cross-registry flow:
47
+ - Caller on Registry A gets token from Registry A
48
+ - Caller sends to agent on Registry B with X-Payment-Issuer: https://api.theprotocol.cloud
49
+ - Agent B's verifier sees issuer != local, checks trusted_registries whitelist
50
+ - If trusted, verifies against Registry A's /a2a-payment/verify endpoint
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ registry_url: str,
56
+ agent_did: str,
57
+ required: bool = True,
58
+ min_amount: Optional[str] = None,
59
+ cache_ttl: int = _CACHE_TTL,
60
+ trusted_registries: Optional[List[str]] = None,
61
+ ):
62
+ self.registry_url = registry_url.rstrip("/")
63
+ self.agent_did = agent_did
64
+ self.required = required
65
+ self.min_amount = Decimal(min_amount) if min_amount else None
66
+ self.cache_ttl = cache_ttl
67
+ # Build trusted set: always trust own registry + any explicitly listed
68
+ self._trusted = {self.registry_url}
69
+ if trusted_registries:
70
+ for url in trusted_registries:
71
+ self._trusted.add(url.rstrip("/"))
72
+
73
+ async def __call__(self, request: Any) -> Dict[str, Any]:
74
+ """FastAPI Depends() entry point."""
75
+ from fastapi import HTTPException
76
+
77
+ token = (
78
+ request.headers.get("X-Payment-Token")
79
+ or request.headers.get("x-payment-token")
80
+ )
81
+
82
+ if not token:
83
+ if self.required:
84
+ raise HTTPException(status_code=402, detail="Payment Required — include X-Payment-Token header")
85
+ request.state.payment = {"verified": False}
86
+ return {"verified": False}
87
+
88
+ # Determine issuing registry
89
+ issuer = (
90
+ request.headers.get("X-Payment-Issuer")
91
+ or request.headers.get("x-payment-issuer")
92
+ or ""
93
+ ).rstrip("/")
94
+
95
+ if issuer and issuer != self.registry_url:
96
+ # Cross-registry token — check whitelist
97
+ if issuer not in self._trusted:
98
+ if self.required:
99
+ raise HTTPException(402, f"Payment issuer not trusted: {issuer}")
100
+ request.state.payment = {"verified": False, "error": "untrusted_issuer"}
101
+ return {"verified": False}
102
+ verify_url = issuer
103
+ else:
104
+ verify_url = self.registry_url
105
+
106
+ # Check cache (bounded, short TTL)
107
+ cache_key = f"{verify_url}:{token}"
108
+ now = time.time()
109
+ cached = _verify_cache.get(cache_key)
110
+ if cached and cached[1] > now:
111
+ result = cached[0]
112
+ else:
113
+ result = await self._verify_with_registry(verify_url, token)
114
+ if result.get("valid"):
115
+ # Prune if at capacity
116
+ if len(_verify_cache) >= _CACHE_MAX:
117
+ expired = [k for k, v in _verify_cache.items() if v[1] <= now]
118
+ for k in expired:
119
+ del _verify_cache[k]
120
+ if len(_verify_cache) >= _CACHE_MAX:
121
+ _verify_cache.clear()
122
+ _verify_cache[cache_key] = (result, now + self.cache_ttl)
123
+
124
+ if not result.get("valid"):
125
+ if self.required:
126
+ raise HTTPException(
127
+ status_code=402,
128
+ detail=f"Payment token invalid: {result.get('error', 'verification failed')}",
129
+ )
130
+ request.state.payment = {"verified": False, "error": result.get("error")}
131
+ return {"verified": False}
132
+
133
+ # Verify target DID
134
+ if result.get("target_agent_did") != self.agent_did:
135
+ if self.required:
136
+ raise HTTPException(402, "Payment token not issued for this agent")
137
+ request.state.payment = {"verified": False, "error": "wrong_target"}
138
+ return {"verified": False}
139
+
140
+ # Check minimum amount
141
+ if self.min_amount and Decimal(result.get("amount", "0")) < self.min_amount:
142
+ if self.required:
143
+ raise HTTPException(402, f"Payment insufficient: {result['amount']} < {self.min_amount}")
144
+ request.state.payment = {"verified": False, "error": "insufficient_amount"}
145
+ return {"verified": False}
146
+
147
+ payment_info = {
148
+ "verified": True,
149
+ "token": token,
150
+ "token_id": result.get("token_id"),
151
+ "caller_did": result.get("caller_did"),
152
+ "target_agent_did": result.get("target_agent_did"),
153
+ "amount": result.get("amount"),
154
+ "contract_id": result.get("contract_id"),
155
+ "issuer_registry": verify_url,
156
+ }
157
+ request.state.payment = payment_info
158
+ return payment_info
159
+
160
+ async def _verify_with_registry(self, registry_url: str, token: str) -> dict:
161
+ """Call a registry's verify endpoint."""
162
+ try:
163
+ async with httpx.AsyncClient(timeout=5.0) as client:
164
+ resp = await client.post(
165
+ f"{registry_url}/api/v1/a2a-payment/verify",
166
+ json={"token": token, "target_agent_did": self.agent_did},
167
+ )
168
+ if resp.status_code == 200:
169
+ return resp.json()
170
+ return {"valid": False, "error": f"Registry returned {resp.status_code}"}
171
+ except Exception as e:
172
+ logger.error(f"[PaymentVerifier] Verification failed ({registry_url}): {e}")
173
+ return {"valid": False, "error": str(e)}
File without changes