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.
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/PKG-INFO +41 -3
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/README.md +38 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/pyproject.toml +3 -3
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/__init__.py +1 -1
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/agent/router.py +80 -2
- theprotocol_sdk-0.4.0/src/theprotocol/payment/__init__.py +33 -0
- theprotocol_sdk-0.4.0/src/theprotocol/payment/authenticator.py +410 -0
- theprotocol_sdk-0.4.0/src/theprotocol/payment/client.py +107 -0
- theprotocol_sdk-0.4.0/src/theprotocol/payment/mtls_client.py +139 -0
- theprotocol_sdk-0.4.0/src/theprotocol/payment/verifier.py +173 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/.gitignore +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/LICENSE +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/examples/simple_agent.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/agent/__init__.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/agent/base.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/agent/decorators.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/agent/task_store.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/__init__.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/acp.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/anp/__init__.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/anp/auth.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/anp/bridge.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/anp/did_wba.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/anp/translation.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/base.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/google_a2a.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/bridges/mcp.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/client/__init__.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/client/a2a_client.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/client/credentials.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/exceptions.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/models/__init__.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/models/a2a_protocol.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/models/agent_card.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/src/theprotocol/models/v1_compat.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/tests/test_agent.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/tests/test_bridge_acp.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/tests/test_bridge_anp.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/tests/test_bridge_google_a2a.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/tests/test_bridge_mcp.py +0 -0
- {theprotocol_sdk-0.2.0 → theprotocol_sdk-0.4.0}/tests/test_credentials.py +0 -0
- {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.
|
|
4
|
-
Summary:
|
|
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,
|
|
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.
|
|
8
|
-
description = "
|
|
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",
|
|
@@ -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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|