a2spa 0.1.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.
Potentially problematic release.
This version of a2spa might be problematic. Click here for more details.
- a2spa-0.1.0/MANIFEST.in +8 -0
- a2spa-0.1.0/PKG-INFO +73 -0
- a2spa-0.1.0/README.md +36 -0
- a2spa-0.1.0/pyproject.toml +42 -0
- a2spa-0.1.0/sdk/README.md +50 -0
- a2spa-0.1.0/sdk/a2spa/__init__.py +313 -0
- a2spa-0.1.0/sdk/a2spa/py.typed +1 -0
- a2spa-0.1.0/sdk/a2spa.egg-info/PKG-INFO +73 -0
- a2spa-0.1.0/sdk/a2spa.egg-info/SOURCES.txt +11 -0
- a2spa-0.1.0/sdk/a2spa.egg-info/dependency_links.txt +1 -0
- a2spa-0.1.0/sdk/a2spa.egg-info/requires.txt +4 -0
- a2spa-0.1.0/sdk/a2spa.egg-info/top_level.txt +1 -0
- a2spa-0.1.0/setup.cfg +4 -0
a2spa-0.1.0/MANIFEST.in
ADDED
a2spa-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
a2spa-0.1.0/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# A2SPA
|
|
2
|
+
|
|
3
|
+
A2SPA is a runtime authorization layer for AI-agent actions. It verifies signed payloads, enforces agent policy, checks state continuity, and emits execution receipts before work is allowed to proceed.
|
|
4
|
+
|
|
5
|
+
This repository contains the Flask verifier application, dashboard, integration docs, and Python/Node payload helper libraries.
|
|
6
|
+
|
|
7
|
+
## What A2SPA Verifies
|
|
8
|
+
|
|
9
|
+
- A registered agent signed the payload with an active key.
|
|
10
|
+
- The payload has not been replayed with a reused nonce.
|
|
11
|
+
- The sender and receiver policies allow the requested action, scope, spend, currency, and delegation claim.
|
|
12
|
+
- Optional state continuity still matches the registered state hash, sequence, status, and expiry.
|
|
13
|
+
- The verifier can emit a signed execution receipt for audit trails.
|
|
14
|
+
|
|
15
|
+
## Public Product Routes
|
|
16
|
+
|
|
17
|
+
- `/A2SPA/` - product homepage.
|
|
18
|
+
- `/A2SPA/docs` - human and AI-assistant integration docs.
|
|
19
|
+
- `/A2SPA/integrations` - integration pack and starter guidance.
|
|
20
|
+
- `/A2SPA/agentspoofed` - agent-spoofing threat page.
|
|
21
|
+
- `/A2SPA/dashboard` - authenticated control plane.
|
|
22
|
+
- `/A2SPA/profile` - authenticated account, balance, API-key metadata, and notification status.
|
|
23
|
+
|
|
24
|
+
## Payload Helper Boundary
|
|
25
|
+
|
|
26
|
+
Distributed helper packages may build payloads, canonicalize them, sign them, and help construct policy/state-continuity claims. Verifier logic and enforcement decisions stay server-side in the Flask application.
|
|
27
|
+
|
|
28
|
+
## Local Verification
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
python -m pytest
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Use `.env.example` for local configuration shape only. Do not commit real `.env` files, Firebase service accounts, receipt-signing keys, API keys, SMTP passwords, or private agent keys.
|
|
35
|
+
|
|
36
|
+
Production operations guidance belongs in private runbooks, not in this repository.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "a2spa"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A2SPA payload helper library for AI-agent runtime authorization"
|
|
9
|
+
readme = "sdk/README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "LicenseRef-Proprietary"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "AI Modularity" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["a2spa", "ai-agents", "authorization", "payload-helpers", "signed-payloads", "runtime-security"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Security",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"cryptography==45.0.5",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
pq = ["pqcrypto==0.4.0"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://aimodularity.com/A2SPA"
|
|
34
|
+
Documentation = "https://aimodularity.com/A2SPA/docs"
|
|
35
|
+
Repository = "https://github.com/gavincapriola/A2SPA"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools]
|
|
38
|
+
package-dir = {"" = "sdk"}
|
|
39
|
+
packages = ["a2spa"]
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.package-data]
|
|
42
|
+
a2spa = ["py.typed"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# A2SPA Python Payload Helpers
|
|
2
|
+
|
|
3
|
+
Helper-only Python package for building, hashing, and signing A2SPA runtime authorization payloads.
|
|
4
|
+
|
|
5
|
+
The verifier remains server-side. This package does not authorize actions by itself.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install a2spa
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
For ML-DSA signing support where the optional wheel is available:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install "a2spa[pq]"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Basic Usage
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from a2spa import build_payload_fields, build_signed_request, policy_input
|
|
23
|
+
|
|
24
|
+
payload = build_payload_fields(
|
|
25
|
+
agent_id="user-123_sender",
|
|
26
|
+
target_agent_id="user-123_receiver",
|
|
27
|
+
input_data=policy_input(action="deliver", workflow_scope="messages:deliver"),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
request_body = build_signed_request(private_key_text, payload)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Send `request_body` to `POST /api/verify_payload` with your account API key in the `x-api-key` header.
|
|
34
|
+
|
|
35
|
+
## Helper Functions
|
|
36
|
+
|
|
37
|
+
- `build_payload_fields(...)`
|
|
38
|
+
- `policy_input(...)`
|
|
39
|
+
- `policy_template(...)`
|
|
40
|
+
- `state_continuity_claim(...)`
|
|
41
|
+
- `canonicalize_payload(...)`
|
|
42
|
+
- `compute_payload_hash(...)`
|
|
43
|
+
- `finalize_payload(...)`
|
|
44
|
+
- `sign_payload(...)`
|
|
45
|
+
- `build_signed_request(...)`
|
|
46
|
+
- `build_debug_snapshot(...)`
|
|
47
|
+
|
|
48
|
+
## Security Boundary
|
|
49
|
+
|
|
50
|
+
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,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
|
+
]
|
|
@@ -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,11 @@
|
|
|
1
|
+
MANIFEST.in
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
sdk/README.md
|
|
5
|
+
sdk/a2spa/__init__.py
|
|
6
|
+
sdk/a2spa/py.typed
|
|
7
|
+
sdk/a2spa.egg-info/PKG-INFO
|
|
8
|
+
sdk/a2spa.egg-info/SOURCES.txt
|
|
9
|
+
sdk/a2spa.egg-info/dependency_links.txt
|
|
10
|
+
sdk/a2spa.egg-info/requires.txt
|
|
11
|
+
sdk/a2spa.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
a2spa
|
a2spa-0.1.0/setup.cfg
ADDED