a2spa 0.1.0__py3-none-any.whl
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.
Potentially problematic release.
This version of a2spa might be problematic. Click here for more details.
- a2spa/__init__.py +313 -0
- a2spa/py.typed +1 -0
- a2spa-0.1.0.dist-info/METADATA +73 -0
- a2spa-0.1.0.dist-info/RECORD +6 -0
- a2spa-0.1.0.dist-info/WHEEL +5 -0
- a2spa-0.1.0.dist-info/top_level.txt +1 -0
a2spa/__init__.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import copy
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import textwrap
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
12
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from pqcrypto.sign import ml_dsa_65 as _mldsa
|
|
16
|
+
except Exception: # pragma: no cover - optional wheel availability is platform-specific
|
|
17
|
+
_mldsa = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
CLASSICAL_RSA_SHA256_PROFILE = "a2spa.classical.rsa_pkcs1v15_sha256.v1"
|
|
21
|
+
PQ_MLDSA_PROFILE = "a2spa.pq.mldsa.v1"
|
|
22
|
+
DEFAULT_SIGNATURE_PROFILE = CLASSICAL_RSA_SHA256_PROFILE
|
|
23
|
+
MLDSA_PRIVATE_KEY_LABEL = "A2SPA ML-DSA-65 PRIVATE KEY"
|
|
24
|
+
SIGNABLE_PAYLOAD_FIELDS = (
|
|
25
|
+
"agent_id",
|
|
26
|
+
"target_agent_id",
|
|
27
|
+
"timestamp",
|
|
28
|
+
"nonce",
|
|
29
|
+
"input",
|
|
30
|
+
"output",
|
|
31
|
+
"alert_threshold",
|
|
32
|
+
)
|
|
33
|
+
OPTIONAL_SIGNABLE_PAYLOAD_FIELDS = (
|
|
34
|
+
"crypto_profile",
|
|
35
|
+
"state_continuity",
|
|
36
|
+
)
|
|
37
|
+
DEFAULT_ALERT_THRESHOLD = 10
|
|
38
|
+
EXPECTED_HASH_RE = re.compile(r"expected:\s*([0-9a-f]{64})", re.IGNORECASE)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _csv(values) -> list[str]:
|
|
42
|
+
if values in (None, ""):
|
|
43
|
+
return []
|
|
44
|
+
if isinstance(values, str):
|
|
45
|
+
values = [values]
|
|
46
|
+
return [str(value).strip() for value in values if str(value).strip()]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def policy_template(template: str, **overrides) -> dict[str, Any]:
|
|
50
|
+
templates = {
|
|
51
|
+
"calendar": {
|
|
52
|
+
"allowed_actions": ["schedule_meeting", "update_event"],
|
|
53
|
+
"allowed_workflow_scopes": ["calendar:write"],
|
|
54
|
+
},
|
|
55
|
+
"email": {
|
|
56
|
+
"allowed_actions": ["send_email", "draft_email"],
|
|
57
|
+
"allowed_workflow_scopes": ["email:send", "email:draft"],
|
|
58
|
+
},
|
|
59
|
+
"payment_reservation": {
|
|
60
|
+
"allowed_actions": ["reserve"],
|
|
61
|
+
"allowed_intents": ["payment_reservation"],
|
|
62
|
+
"allowed_workflow_scopes": ["payments:reserve"],
|
|
63
|
+
"allowed_currencies": ["USD"],
|
|
64
|
+
"max_spend_usd": 100.0,
|
|
65
|
+
"require_state_continuity_for_spend": True,
|
|
66
|
+
},
|
|
67
|
+
"crm_write": {
|
|
68
|
+
"allowed_actions": ["create_contact", "update_contact"],
|
|
69
|
+
"allowed_workflow_scopes": ["crm:write"],
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
if template not in templates:
|
|
73
|
+
raise ValueError(f"Unknown policy template: {template}")
|
|
74
|
+
policy = dict(templates[template])
|
|
75
|
+
policy.update({key: value for key, value in overrides.items() if value is not None})
|
|
76
|
+
return policy
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def policy_input(
|
|
80
|
+
*,
|
|
81
|
+
action: str | None = None,
|
|
82
|
+
intent: str | None = None,
|
|
83
|
+
workflow_scope: str | None = None,
|
|
84
|
+
workflow_scopes=None,
|
|
85
|
+
amount_usd: str | float | None = None,
|
|
86
|
+
currency: str | None = None,
|
|
87
|
+
on_behalf_of: str | None = None,
|
|
88
|
+
**extra,
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
data = dict(extra)
|
|
91
|
+
if action:
|
|
92
|
+
data["action"] = action
|
|
93
|
+
if intent:
|
|
94
|
+
data["intent"] = intent
|
|
95
|
+
scopes = _csv(workflow_scopes)
|
|
96
|
+
if workflow_scope:
|
|
97
|
+
data["workflow_scope"] = workflow_scope
|
|
98
|
+
elif scopes:
|
|
99
|
+
data["workflow_scopes"] = scopes
|
|
100
|
+
if amount_usd is not None:
|
|
101
|
+
data["amount_usd"] = str(amount_usd)
|
|
102
|
+
if currency:
|
|
103
|
+
data["currency"] = currency
|
|
104
|
+
if on_behalf_of:
|
|
105
|
+
data["on_behalf_of"] = on_behalf_of
|
|
106
|
+
return data
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def canonicalize_value(value: Any) -> str:
|
|
110
|
+
return json.dumps(value, sort_keys=True)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def canonical_state_hash(state: dict) -> str:
|
|
114
|
+
if not isinstance(state, dict):
|
|
115
|
+
raise ValueError("state must be an object")
|
|
116
|
+
return hashlib.sha256(canonicalize_value(state).encode("utf-8")).hexdigest()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def state_continuity_claim(state_id: str, state: dict, sequence: int, expires_at: str | None = None) -> dict[str, Any]:
|
|
120
|
+
claim = {
|
|
121
|
+
"state_id": state_id,
|
|
122
|
+
"state_hash": canonical_state_hash(state),
|
|
123
|
+
"sequence": int(sequence),
|
|
124
|
+
}
|
|
125
|
+
if expires_at:
|
|
126
|
+
claim["expires_at"] = expires_at
|
|
127
|
+
return claim
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def strip_security_fields(payload: dict) -> dict:
|
|
131
|
+
return {key: copy.deepcopy(value) for key, value in payload.items() if key not in {"hash", "signature"}}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def normalize_signable_payload(payload: dict) -> dict:
|
|
135
|
+
normalized = {field: copy.deepcopy(payload[field]) for field in SIGNABLE_PAYLOAD_FIELDS if field in payload}
|
|
136
|
+
normalized.setdefault("alert_threshold", DEFAULT_ALERT_THRESHOLD)
|
|
137
|
+
for field in OPTIONAL_SIGNABLE_PAYLOAD_FIELDS:
|
|
138
|
+
if field in payload and payload[field] is not None:
|
|
139
|
+
normalized[field] = copy.deepcopy(payload[field])
|
|
140
|
+
return normalized
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def canonicalize_payload(payload: dict) -> str:
|
|
144
|
+
return json.dumps(normalize_signable_payload(payload), sort_keys=True)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def prepare_signed_bytes(payload: dict) -> bytes:
|
|
148
|
+
return canonicalize_payload(payload).encode("utf-8")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def compute_payload_hash(payload: dict) -> str:
|
|
152
|
+
return hashlib.sha256(prepare_signed_bytes(payload)).hexdigest()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def finalize_payload(payload_before_hash: dict) -> dict:
|
|
156
|
+
finalized = strip_security_fields(payload_before_hash)
|
|
157
|
+
finalized["hash"] = compute_payload_hash(finalized)
|
|
158
|
+
return finalized
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def build_request_body(payload: dict, signature: str) -> dict:
|
|
162
|
+
return {
|
|
163
|
+
"payload": copy.deepcopy(payload),
|
|
164
|
+
"signature": signature,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def build_payload_fields(
|
|
169
|
+
*,
|
|
170
|
+
agent_id: str,
|
|
171
|
+
target_agent_id: str,
|
|
172
|
+
input_data: Any,
|
|
173
|
+
output_data: Any = None,
|
|
174
|
+
alert_threshold: int = 10,
|
|
175
|
+
signature_profile: str = DEFAULT_SIGNATURE_PROFILE,
|
|
176
|
+
state_continuity: dict | None = None,
|
|
177
|
+
nonce: str | None = None,
|
|
178
|
+
timestamp: str | None = None,
|
|
179
|
+
) -> dict[str, Any]:
|
|
180
|
+
payload = {
|
|
181
|
+
"agent_id": agent_id,
|
|
182
|
+
"target_agent_id": target_agent_id,
|
|
183
|
+
"timestamp": timestamp or datetime.now(timezone.utc).isoformat(),
|
|
184
|
+
"nonce": nonce or str(uuid.uuid4()),
|
|
185
|
+
"input": input_data,
|
|
186
|
+
"output": output_data if output_data is not None else {"status": "ready"},
|
|
187
|
+
"alert_threshold": alert_threshold,
|
|
188
|
+
"crypto_profile": signature_profile,
|
|
189
|
+
}
|
|
190
|
+
if state_continuity:
|
|
191
|
+
payload["state_continuity"] = state_continuity
|
|
192
|
+
return payload
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def sign_payload_rsa(private_key_pem: str, payload: dict) -> str:
|
|
196
|
+
private_key = serialization.load_pem_private_key(private_key_pem.encode("utf-8"), password=None)
|
|
197
|
+
return private_key.sign(
|
|
198
|
+
prepare_signed_bytes(payload),
|
|
199
|
+
padding.PKCS1v15(),
|
|
200
|
+
hashes.SHA256(),
|
|
201
|
+
).hex()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _dearmor_mldsa_private_key(key_text: str) -> bytes:
|
|
205
|
+
if _mldsa is None:
|
|
206
|
+
raise RuntimeError("pqcrypto is required for ML-DSA signing")
|
|
207
|
+
if not isinstance(key_text, str):
|
|
208
|
+
raise ValueError("Invalid ML-DSA private key")
|
|
209
|
+
|
|
210
|
+
begin = f"-----BEGIN {MLDSA_PRIVATE_KEY_LABEL}-----"
|
|
211
|
+
end = f"-----END {MLDSA_PRIVATE_KEY_LABEL}-----"
|
|
212
|
+
normalized = key_text.strip().replace("\r\n", "\n")
|
|
213
|
+
if not normalized.startswith(begin) or not normalized.endswith(end):
|
|
214
|
+
raise ValueError("Invalid ML-DSA private key envelope")
|
|
215
|
+
|
|
216
|
+
body = normalized[len(begin) : -len(end)]
|
|
217
|
+
raw = base64.b64decode("".join(body.split()), validate=True)
|
|
218
|
+
if len(raw) != _mldsa.SECRET_KEY_SIZE:
|
|
219
|
+
raise ValueError("Invalid ML-DSA private key length")
|
|
220
|
+
return raw
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def sign_payload_mldsa(private_key_text: str, payload: dict) -> str:
|
|
224
|
+
if _mldsa is None:
|
|
225
|
+
raise RuntimeError("pqcrypto is required for ML-DSA signing")
|
|
226
|
+
return _mldsa.sign(_dearmor_mldsa_private_key(private_key_text), prepare_signed_bytes(payload)).hex()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def sign_payload(private_key_text: str, payload: dict, signature_profile: str | None = None) -> str:
|
|
230
|
+
profile = signature_profile or payload.get("crypto_profile") or CLASSICAL_RSA_SHA256_PROFILE
|
|
231
|
+
if profile == PQ_MLDSA_PROFILE:
|
|
232
|
+
return sign_payload_mldsa(private_key_text, payload)
|
|
233
|
+
if profile == CLASSICAL_RSA_SHA256_PROFILE:
|
|
234
|
+
return sign_payload_rsa(private_key_text, payload)
|
|
235
|
+
raise ValueError(f"Unsupported signature profile for client signing: {profile}")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def build_signed_request(private_key_text: str, payload_before_hash: dict) -> dict[str, Any]:
|
|
239
|
+
payload = finalize_payload(payload_before_hash)
|
|
240
|
+
signature = sign_payload(private_key_text, payload_before_hash, payload_before_hash.get("crypto_profile"))
|
|
241
|
+
return build_request_body(payload, signature)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def extract_expected_hash(error_text: str | None) -> str | None:
|
|
245
|
+
if not error_text:
|
|
246
|
+
return None
|
|
247
|
+
match = EXPECTED_HASH_RE.search(str(error_text))
|
|
248
|
+
return match.group(1) if match else None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def build_debug_snapshot(
|
|
252
|
+
payload_before_hash: dict,
|
|
253
|
+
signature: str | None = None,
|
|
254
|
+
final_payload: dict | None = None,
|
|
255
|
+
server_error_text: str | None = None,
|
|
256
|
+
) -> dict[str, Any]:
|
|
257
|
+
pre_hash_payload = strip_security_fields(payload_before_hash)
|
|
258
|
+
request_payload = copy.deepcopy(final_payload) if final_payload is not None else finalize_payload(pre_hash_payload)
|
|
259
|
+
canonical_string = canonicalize_payload(pre_hash_payload)
|
|
260
|
+
signed_bytes = canonical_string.encode("utf-8")
|
|
261
|
+
snapshot = {
|
|
262
|
+
"payload_before_hash": pre_hash_payload,
|
|
263
|
+
"canonical_string": canonical_string,
|
|
264
|
+
"canonical_bytes_hex": signed_bytes.hex(),
|
|
265
|
+
"client_computed_hash": hashlib.sha256(signed_bytes).hexdigest(),
|
|
266
|
+
"final_request_body": build_request_body(request_payload, signature) if signature is not None else {"payload": request_payload},
|
|
267
|
+
"server_expected_hash": extract_expected_hash(server_error_text),
|
|
268
|
+
}
|
|
269
|
+
if server_error_text is not None:
|
|
270
|
+
snapshot["server_error"] = server_error_text
|
|
271
|
+
return snapshot
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class A2SPAClient:
|
|
275
|
+
def __init__(self, agent_id: str, private_key_text: str):
|
|
276
|
+
self.agent_id = agent_id
|
|
277
|
+
self.private_key_text = private_key_text
|
|
278
|
+
|
|
279
|
+
def build_signed_request(self, target_agent_id: str, input_data: Any, output_data: Any = None, **kwargs):
|
|
280
|
+
payload = build_payload_fields(
|
|
281
|
+
agent_id=self.agent_id,
|
|
282
|
+
target_agent_id=target_agent_id,
|
|
283
|
+
input_data=input_data,
|
|
284
|
+
output_data=output_data,
|
|
285
|
+
**kwargs,
|
|
286
|
+
)
|
|
287
|
+
return build_signed_request(self.private_key_text, payload)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
__all__ = [
|
|
291
|
+
"A2SPAClient",
|
|
292
|
+
"CLASSICAL_RSA_SHA256_PROFILE",
|
|
293
|
+
"PQ_MLDSA_PROFILE",
|
|
294
|
+
"build_debug_snapshot",
|
|
295
|
+
"build_payload_fields",
|
|
296
|
+
"build_request_body",
|
|
297
|
+
"build_signed_request",
|
|
298
|
+
"canonical_state_hash",
|
|
299
|
+
"canonicalize_payload",
|
|
300
|
+
"canonicalize_value",
|
|
301
|
+
"compute_payload_hash",
|
|
302
|
+
"extract_expected_hash",
|
|
303
|
+
"finalize_payload",
|
|
304
|
+
"normalize_signable_payload",
|
|
305
|
+
"policy_input",
|
|
306
|
+
"policy_template",
|
|
307
|
+
"prepare_signed_bytes",
|
|
308
|
+
"sign_payload",
|
|
309
|
+
"sign_payload_mldsa",
|
|
310
|
+
"sign_payload_rsa",
|
|
311
|
+
"state_continuity_claim",
|
|
312
|
+
"strip_security_fields",
|
|
313
|
+
]
|
a2spa/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
partial
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: a2spa
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A2SPA payload helper library for AI-agent runtime authorization
|
|
5
|
+
Author: AI Modularity
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Project-URL: Homepage, https://aimodularity.com/A2SPA
|
|
8
|
+
Project-URL: Documentation, https://aimodularity.com/A2SPA/docs
|
|
9
|
+
Project-URL: Repository, https://github.com/gavincapriola/A2SPA
|
|
10
|
+
Keywords: a2spa,ai-agents,authorization,payload-helpers,signed-payloads,runtime-security
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Security
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: cryptography==45.0.5
|
|
21
|
+
Provides-Extra: pq
|
|
22
|
+
Requires-Dist: pqcrypto==0.4.0; extra == "pq"
|
|
23
|
+
|
|
24
|
+
# A2SPA Python Payload Helpers
|
|
25
|
+
|
|
26
|
+
Helper-only Python package for building, hashing, and signing A2SPA runtime authorization payloads.
|
|
27
|
+
|
|
28
|
+
The verifier remains server-side. This package does not authorize actions by itself.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install a2spa
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
For ML-DSA signing support where the optional wheel is available:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install "a2spa[pq]"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Basic Usage
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from a2spa import build_payload_fields, build_signed_request, policy_input
|
|
46
|
+
|
|
47
|
+
payload = build_payload_fields(
|
|
48
|
+
agent_id="user-123_sender",
|
|
49
|
+
target_agent_id="user-123_receiver",
|
|
50
|
+
input_data=policy_input(action="deliver", workflow_scope="messages:deliver"),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
request_body = build_signed_request(private_key_text, payload)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Send `request_body` to `POST /api/verify_payload` with your account API key in the `x-api-key` header.
|
|
57
|
+
|
|
58
|
+
## Helper Functions
|
|
59
|
+
|
|
60
|
+
- `build_payload_fields(...)`
|
|
61
|
+
- `policy_input(...)`
|
|
62
|
+
- `policy_template(...)`
|
|
63
|
+
- `state_continuity_claim(...)`
|
|
64
|
+
- `canonicalize_payload(...)`
|
|
65
|
+
- `compute_payload_hash(...)`
|
|
66
|
+
- `finalize_payload(...)`
|
|
67
|
+
- `sign_payload(...)`
|
|
68
|
+
- `build_signed_request(...)`
|
|
69
|
+
- `build_debug_snapshot(...)`
|
|
70
|
+
|
|
71
|
+
## Security Boundary
|
|
72
|
+
|
|
73
|
+
Private keys must stay in a secret manager, server environment, or deployment secret. Do not paste private keys into chats, logs, client-side code, or source control.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
a2spa/__init__.py,sha256=sjh8oEn2CrH12uk_Wn3FL-7nQbSez8Ad2EpTGGReMs4,10565
|
|
2
|
+
a2spa/py.typed,sha256=la67KBlbjXN-_-DfGNcdOcjYumVpKG_Tkw-8n5dnGB4,8
|
|
3
|
+
a2spa-0.1.0.dist-info/METADATA,sha256=VRAjgJ2lDEWQ-jzc0uO3VDXhSg7pa5omvuJpWtUKXxc,2355
|
|
4
|
+
a2spa-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
a2spa-0.1.0.dist-info/top_level.txt,sha256=5Krwx745tG4iHAhA1uz15_BCXA8QAuGjfhcSy9xWB7E,6
|
|
6
|
+
a2spa-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
a2spa
|