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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ a2spa