capforge 0.4.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.
- capforge/__init__.py +50 -0
- capforge/_acm.py +491 -0
- capforge/_crypto.py +149 -0
- capforge/_delegation.py +83 -0
- capforge/_exceptions.py +21 -0
- capforge/_model.py +176 -0
- capforge/_policy.py +341 -0
- capforge/_validator.py +71 -0
- capforge/_version.py +3 -0
- capforge/cli/__init__.py +1 -0
- capforge/cli/main.py +347 -0
- capforge/py.typed +0 -0
- capforge-0.4.0.dist-info/METADATA +568 -0
- capforge-0.4.0.dist-info/RECORD +21 -0
- capforge-0.4.0.dist-info/WHEEL +5 -0
- capforge-0.4.0.dist-info/entry_points.txt +2 -0
- capforge-0.4.0.dist-info/licenses/LICENSE +201 -0
- capforge-0.4.0.dist-info/top_level.txt +2 -0
- capforge_langgraph/__init__.py +36 -0
- capforge_langgraph/_tool_wrapper.py +219 -0
- capforge_langgraph/pyproject.toml +35 -0
capforge/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""CapForge — Agent Capability Manifest (ACM) SDK.
|
|
2
|
+
|
|
3
|
+
CapForge is the reference Python implementation of the Agent Capability
|
|
4
|
+
Manifest (ACM) protocol. It provides a clean API for creating, signing,
|
|
5
|
+
verifying, delegating, saving, and loading ACM documents.
|
|
6
|
+
|
|
7
|
+
Basic usage::
|
|
8
|
+
|
|
9
|
+
from capforge import ACM, Capability, Constraints
|
|
10
|
+
|
|
11
|
+
# Create a new manifest
|
|
12
|
+
manifest = ACM.create(
|
|
13
|
+
spiffe_id="spiffe://org/agent/my-bot",
|
|
14
|
+
sponsor="user@org.com",
|
|
15
|
+
capabilities=[Capability(resource="kb", action="read")],
|
|
16
|
+
constraints=Constraints(max_cost_per_session=0.05),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Sign, save, load, verify
|
|
20
|
+
manifest.sign("my-key.pem").save("manifest.json")
|
|
21
|
+
loaded = ACM.load("manifest.json")
|
|
22
|
+
loaded.verify(trusted_keys=["my-key.pub"])
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from capforge._acm import ACM
|
|
28
|
+
from capforge._exceptions import (
|
|
29
|
+
ACMError,
|
|
30
|
+
ACMExpiredError,
|
|
31
|
+
ACMInvalidError,
|
|
32
|
+
ACMSignatureError,
|
|
33
|
+
ACMValidationError,
|
|
34
|
+
)
|
|
35
|
+
from capforge._model import Capability, Constraints
|
|
36
|
+
from capforge._policy import PolicyDecision
|
|
37
|
+
from capforge._version import __version__
|
|
38
|
+
|
|
39
|
+
__all__: list[str] = [
|
|
40
|
+
"ACM",
|
|
41
|
+
"ACMError",
|
|
42
|
+
"ACMExpiredError",
|
|
43
|
+
"ACMInvalidError",
|
|
44
|
+
"ACMSignatureError",
|
|
45
|
+
"ACMValidationError",
|
|
46
|
+
"Capability",
|
|
47
|
+
"Constraints",
|
|
48
|
+
"PolicyDecision",
|
|
49
|
+
"__version__",
|
|
50
|
+
]
|
capforge/_acm.py
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""Main public API for the CapForge SDK.
|
|
2
|
+
|
|
3
|
+
The ``ACM`` class provides the primary interface for creating, signing,
|
|
4
|
+
verifying, delegating, saving, and loading Agent Capability Manifest
|
|
5
|
+
documents.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from datetime import UTC, datetime, timedelta
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
16
|
+
|
|
17
|
+
from capforge._crypto import (
|
|
18
|
+
load_private_key,
|
|
19
|
+
load_public_key,
|
|
20
|
+
sign_dict,
|
|
21
|
+
verify_dict,
|
|
22
|
+
)
|
|
23
|
+
from capforge._delegation import delegate as _delegate_model
|
|
24
|
+
from capforge._exceptions import ACMError, ACMSignatureError
|
|
25
|
+
from capforge._model import (
|
|
26
|
+
Capability,
|
|
27
|
+
Constraints,
|
|
28
|
+
_ACMModel,
|
|
29
|
+
_AgentInfo,
|
|
30
|
+
)
|
|
31
|
+
from capforge._policy import PolicyDecision, RuntimeEvaluator
|
|
32
|
+
from capforge._validator import validate as _validate
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ACM:
|
|
36
|
+
"""Agent Capability Manifest — the primary interface for ACM documents.
|
|
37
|
+
|
|
38
|
+
Create, sign, verify, delegate, save, and load Agent Capability Manifest
|
|
39
|
+
documents.
|
|
40
|
+
|
|
41
|
+
Basic usage::
|
|
42
|
+
|
|
43
|
+
from capforge import ACM, Capability, Constraints
|
|
44
|
+
|
|
45
|
+
# Create a new manifest
|
|
46
|
+
manifest = ACM.create(
|
|
47
|
+
spiffe_id="spiffe://org/agent/my-bot",
|
|
48
|
+
sponsor="user@org.com",
|
|
49
|
+
capabilities=[Capability(resource="kb", action="read")],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Sign and save it
|
|
53
|
+
manifest.sign("my-key.pem")
|
|
54
|
+
manifest.save("manifest.json")
|
|
55
|
+
|
|
56
|
+
# Load and verify
|
|
57
|
+
loaded = ACM.load("manifest.json")
|
|
58
|
+
loaded.verify()
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, model: _ACMModel) -> None:
|
|
62
|
+
self._model = model
|
|
63
|
+
|
|
64
|
+
# ── Factory Methods ───────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def create(
|
|
68
|
+
cls,
|
|
69
|
+
spiffe_id: str,
|
|
70
|
+
sponsor: str,
|
|
71
|
+
capabilities: list[Capability],
|
|
72
|
+
*,
|
|
73
|
+
issuer: str | None = None,
|
|
74
|
+
constraints: Constraints | None = None,
|
|
75
|
+
ttl: int = 3600,
|
|
76
|
+
not_before: datetime | None = None,
|
|
77
|
+
metadata: dict[str, Any] | None = None,
|
|
78
|
+
model: str | None = None,
|
|
79
|
+
provider: str | None = None,
|
|
80
|
+
) -> ACM:
|
|
81
|
+
"""Create a new ACM manifest.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
spiffe_id:
|
|
86
|
+
SPIFFE ID of the agent being authorized.
|
|
87
|
+
sponsor:
|
|
88
|
+
Email or SPIFFE ID of the responsible human or team.
|
|
89
|
+
capabilities:
|
|
90
|
+
List of capabilities to grant.
|
|
91
|
+
issuer:
|
|
92
|
+
SPIFFE ID of the issuing entity. Defaults to *spiffe_id*.
|
|
93
|
+
constraints:
|
|
94
|
+
Optional global constraints.
|
|
95
|
+
ttl:
|
|
96
|
+
Time-to-live in seconds (default 3600 = 1 hour).
|
|
97
|
+
not_before:
|
|
98
|
+
Optional ISO 8601 timestamp before which the manifest is invalid.
|
|
99
|
+
metadata:
|
|
100
|
+
Optional implementation-specific metadata.
|
|
101
|
+
model:
|
|
102
|
+
Optional LLM model name (e.g., ``"gpt-5"``).
|
|
103
|
+
provider:
|
|
104
|
+
Optional model provider (e.g., ``"openai"``).
|
|
105
|
+
|
|
106
|
+
Returns
|
|
107
|
+
-------
|
|
108
|
+
ACM
|
|
109
|
+
A new unsigned ACM manifest.
|
|
110
|
+
"""
|
|
111
|
+
now = datetime.now(UTC)
|
|
112
|
+
acm_model = _ACMModel(
|
|
113
|
+
agent=_AgentInfo(
|
|
114
|
+
spiffe_id=spiffe_id,
|
|
115
|
+
model=model,
|
|
116
|
+
provider=provider,
|
|
117
|
+
),
|
|
118
|
+
human_sponsor=sponsor,
|
|
119
|
+
capabilities=capabilities,
|
|
120
|
+
constraints=constraints,
|
|
121
|
+
issuer=issuer or spiffe_id,
|
|
122
|
+
expires_at=now + timedelta(seconds=ttl),
|
|
123
|
+
not_before=not_before or now,
|
|
124
|
+
metadata=metadata,
|
|
125
|
+
)
|
|
126
|
+
return cls(acm_model)
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def load(cls, source: str | Path | dict[str, Any]) -> ACM:
|
|
130
|
+
"""Load an ACM manifest from a JSON file or dictionary.
|
|
131
|
+
|
|
132
|
+
Parameters
|
|
133
|
+
----------
|
|
134
|
+
source:
|
|
135
|
+
Path to a JSON file, or a dictionary representing the ACM.
|
|
136
|
+
|
|
137
|
+
Returns
|
|
138
|
+
-------
|
|
139
|
+
ACM
|
|
140
|
+
The loaded manifest.
|
|
141
|
+
"""
|
|
142
|
+
if isinstance(source, (str, Path)):
|
|
143
|
+
path = Path(source)
|
|
144
|
+
with path.open() as f:
|
|
145
|
+
data = json.load(f)
|
|
146
|
+
else:
|
|
147
|
+
data = source
|
|
148
|
+
|
|
149
|
+
model = _ACMModel.model_validate(data)
|
|
150
|
+
return cls(model)
|
|
151
|
+
|
|
152
|
+
# ── Persistence ───────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
def save(self, path: str | Path | None = None) -> str:
|
|
155
|
+
"""Save this manifest to a JSON file and/or return its JSON string.
|
|
156
|
+
|
|
157
|
+
Parameters
|
|
158
|
+
----------
|
|
159
|
+
path:
|
|
160
|
+
Optional file path. If provided, the JSON is written to this file.
|
|
161
|
+
|
|
162
|
+
Returns
|
|
163
|
+
-------
|
|
164
|
+
str
|
|
165
|
+
The pretty-printed JSON string of this manifest.
|
|
166
|
+
"""
|
|
167
|
+
result = self._model.model_dump_json(indent=2)
|
|
168
|
+
if path is not None:
|
|
169
|
+
Path(path).write_text(result + "\n")
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
def to_dict(self) -> dict[str, Any]:
|
|
173
|
+
"""Return this manifest as a dictionary (JSON-safe)."""
|
|
174
|
+
return self._model.model_dump()
|
|
175
|
+
|
|
176
|
+
def to_json(self) -> str:
|
|
177
|
+
"""Return this manifest as a pretty-printed JSON string."""
|
|
178
|
+
return self._model.model_dump_json(indent=2)
|
|
179
|
+
|
|
180
|
+
# ── Signing ───────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
def sign(self, key: Ed25519PrivateKey | str | Path) -> ACM:
|
|
183
|
+
"""Sign this manifest with an Ed25519 private key.
|
|
184
|
+
|
|
185
|
+
The signature is added to the manifest in-place.
|
|
186
|
+
|
|
187
|
+
Parameters
|
|
188
|
+
----------
|
|
189
|
+
key:
|
|
190
|
+
An ``Ed25519PrivateKey`` instance, or a path to a PEM file.
|
|
191
|
+
|
|
192
|
+
Returns
|
|
193
|
+
-------
|
|
194
|
+
ACM
|
|
195
|
+
``self``, with the signature attached (for chaining).
|
|
196
|
+
"""
|
|
197
|
+
if isinstance(key, (str, Path)):
|
|
198
|
+
key = load_private_key(key)
|
|
199
|
+
|
|
200
|
+
acm_dict = json.loads(self._model.model_dump_json())
|
|
201
|
+
self._model.signature = sign_dict(acm_dict, key)
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
# ── Verification ──────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
def verify(
|
|
207
|
+
self,
|
|
208
|
+
*,
|
|
209
|
+
trusted_keys: list[str | Path | Ed25519PublicKey] | None = None,
|
|
210
|
+
) -> None:
|
|
211
|
+
"""Verify this manifest's temporal validity and optionally its signature.
|
|
212
|
+
|
|
213
|
+
Parameters
|
|
214
|
+
----------
|
|
215
|
+
trusted_keys:
|
|
216
|
+
Optional list of trusted public keys (PEM paths or key instances)
|
|
217
|
+
to verify the signature against. If provided, signature verification
|
|
218
|
+
is performed against each key until one succeeds.
|
|
219
|
+
|
|
220
|
+
Raises
|
|
221
|
+
------
|
|
222
|
+
ACMExpiredError
|
|
223
|
+
If the manifest has expired or is not yet valid.
|
|
224
|
+
ACMValidationError
|
|
225
|
+
If the manifest fails structural or delegation chain validation.
|
|
226
|
+
ACMSignatureError
|
|
227
|
+
If the signature is missing, invalid, or cannot be verified.
|
|
228
|
+
"""
|
|
229
|
+
_validate(self._model)
|
|
230
|
+
|
|
231
|
+
if trusted_keys is not None:
|
|
232
|
+
if self._model.signature is None:
|
|
233
|
+
raise ACMSignatureError("Cannot verify signature: no signature in manifest.")
|
|
234
|
+
self._verify_signature(trusted_keys)
|
|
235
|
+
|
|
236
|
+
def _verify_signature(
|
|
237
|
+
self,
|
|
238
|
+
trusted_keys: list[str | Path | Ed25519PublicKey],
|
|
239
|
+
) -> None:
|
|
240
|
+
"""Verify the signature against one or more trusted keys."""
|
|
241
|
+
acm_dict = json.loads(self._model.model_dump_json())
|
|
242
|
+
signature = self._model.signature
|
|
243
|
+
if signature is None:
|
|
244
|
+
raise ACMSignatureError("No signature to verify")
|
|
245
|
+
|
|
246
|
+
errors: list[str] = []
|
|
247
|
+
for key_source in trusted_keys:
|
|
248
|
+
try:
|
|
249
|
+
if isinstance(key_source, (str, Path)):
|
|
250
|
+
pub_key = load_public_key(key_source)
|
|
251
|
+
else:
|
|
252
|
+
pub_key = key_source
|
|
253
|
+
|
|
254
|
+
if verify_dict(acm_dict, signature, pub_key):
|
|
255
|
+
return
|
|
256
|
+
except ACMError as e:
|
|
257
|
+
name = str(key_source) if isinstance(key_source, (str, Path)) else "provided key"
|
|
258
|
+
errors.append(f"{name}: {e}")
|
|
259
|
+
|
|
260
|
+
if errors:
|
|
261
|
+
details = "; ".join(errors)
|
|
262
|
+
raise ACMSignatureError(
|
|
263
|
+
f"Signature could not be verified against any trusted key: {details}"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# ── Delegation ────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
def delegate(
|
|
269
|
+
self,
|
|
270
|
+
child_spiffe_id: str,
|
|
271
|
+
capabilities: list[Capability],
|
|
272
|
+
*,
|
|
273
|
+
ttl: int = 3600,
|
|
274
|
+
key: Ed25519PrivateKey | str | Path | None = None,
|
|
275
|
+
) -> ACM:
|
|
276
|
+
"""Create a delegated ACM with narrowed scope.
|
|
277
|
+
|
|
278
|
+
Parameters
|
|
279
|
+
----------
|
|
280
|
+
child_spiffe_id:
|
|
281
|
+
SPIFFE ID of the child agent.
|
|
282
|
+
capabilities:
|
|
283
|
+
Capabilities to delegate. Must be a subset of the parent's.
|
|
284
|
+
ttl:
|
|
285
|
+
Time-to-live in seconds for the delegated ACM.
|
|
286
|
+
key:
|
|
287
|
+
Optional private key to sign the delegated manifest.
|
|
288
|
+
|
|
289
|
+
Returns
|
|
290
|
+
-------
|
|
291
|
+
ACM
|
|
292
|
+
A new ACM instance representing the delegated manifest.
|
|
293
|
+
"""
|
|
294
|
+
child_model = _delegate_model(
|
|
295
|
+
parent=self._model,
|
|
296
|
+
child_spiffe_id=child_spiffe_id,
|
|
297
|
+
child_capabilities=capabilities,
|
|
298
|
+
ttl=ttl,
|
|
299
|
+
)
|
|
300
|
+
child = ACM(child_model)
|
|
301
|
+
|
|
302
|
+
if key is not None:
|
|
303
|
+
child.sign(key)
|
|
304
|
+
|
|
305
|
+
return child
|
|
306
|
+
|
|
307
|
+
# ── Runtime Authorization Check ──────────────────────────────
|
|
308
|
+
|
|
309
|
+
def check(
|
|
310
|
+
self,
|
|
311
|
+
resource: str | None = None,
|
|
312
|
+
action: str | None = None,
|
|
313
|
+
*,
|
|
314
|
+
tool: str | None = None,
|
|
315
|
+
cost: float | None = None,
|
|
316
|
+
session_cost: float | None = None,
|
|
317
|
+
session_tokens: int | None = None,
|
|
318
|
+
model: str | None = None,
|
|
319
|
+
execution_seconds: float | None = None,
|
|
320
|
+
) -> PolicyDecision:
|
|
321
|
+
"""Evaluate whether an action is authorized right now.
|
|
322
|
+
|
|
323
|
+
This is the runtime authorization engine — it checks temporal
|
|
324
|
+
validity, capability presence, and all constraints (budget,
|
|
325
|
+
models, tools, execution time) in a single call.
|
|
326
|
+
|
|
327
|
+
Parameters
|
|
328
|
+
----------
|
|
329
|
+
resource:
|
|
330
|
+
Resource type to check (e.g., ``"knowledge_base"``).
|
|
331
|
+
action:
|
|
332
|
+
Action to check (e.g., ``"read"``).
|
|
333
|
+
tool:
|
|
334
|
+
Tool name to check against ``disallowed_tools``.
|
|
335
|
+
cost:
|
|
336
|
+
Cost of this single action in USD.
|
|
337
|
+
session_cost:
|
|
338
|
+
Total cost so far in the current session in USD.
|
|
339
|
+
session_tokens:
|
|
340
|
+
Total tokens so far in the current session.
|
|
341
|
+
model:
|
|
342
|
+
Model name to check against ``allowed_models``.
|
|
343
|
+
execution_seconds:
|
|
344
|
+
Execution time so far in seconds.
|
|
345
|
+
|
|
346
|
+
Returns
|
|
347
|
+
-------
|
|
348
|
+
PolicyDecision
|
|
349
|
+
A structured decision with ``allowed``, ``reason``,
|
|
350
|
+
``failed_constraint``, and ``remaining_budget``.
|
|
351
|
+
|
|
352
|
+
Example::
|
|
353
|
+
|
|
354
|
+
result = manifest.check(
|
|
355
|
+
resource="github",
|
|
356
|
+
action="search",
|
|
357
|
+
cost=0.02,
|
|
358
|
+
session_cost=0.20,
|
|
359
|
+
session_tokens=1000,
|
|
360
|
+
tool="github.search",
|
|
361
|
+
model="gpt-5",
|
|
362
|
+
)
|
|
363
|
+
if result.allowed:
|
|
364
|
+
print(f"Allowed — remaining budget: {result.remaining_budget}")
|
|
365
|
+
else:
|
|
366
|
+
print(f"Denied: {result.reason} ({result.failed_constraint})")
|
|
367
|
+
"""
|
|
368
|
+
evaluator = RuntimeEvaluator()
|
|
369
|
+
return evaluator.check(
|
|
370
|
+
self._model,
|
|
371
|
+
resource=resource,
|
|
372
|
+
action=action,
|
|
373
|
+
tool=tool,
|
|
374
|
+
cost=cost,
|
|
375
|
+
session_cost=session_cost,
|
|
376
|
+
session_tokens=session_tokens,
|
|
377
|
+
model=model,
|
|
378
|
+
execution_seconds=execution_seconds,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# ── Capability Lookup ─────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
def has_capability(self, resource: str, action: str) -> bool:
|
|
384
|
+
"""Check if a specific capability is present in this manifest.
|
|
385
|
+
|
|
386
|
+
Parameters
|
|
387
|
+
----------
|
|
388
|
+
resource:
|
|
389
|
+
The resource type to check.
|
|
390
|
+
action:
|
|
391
|
+
The action to check.
|
|
392
|
+
|
|
393
|
+
Returns
|
|
394
|
+
-------
|
|
395
|
+
bool
|
|
396
|
+
True if the capability exists in this manifest.
|
|
397
|
+
"""
|
|
398
|
+
return any(c.resource == resource and c.action == action for c in self._model.capabilities)
|
|
399
|
+
|
|
400
|
+
def get_capability(self, resource: str, action: str) -> Capability | None:
|
|
401
|
+
"""Get a specific capability by resource and action.
|
|
402
|
+
|
|
403
|
+
Returns the ``Capability`` if found, or ``None``.
|
|
404
|
+
"""
|
|
405
|
+
for c in self._model.capabilities:
|
|
406
|
+
if c.resource == resource and c.action == action:
|
|
407
|
+
return c
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
# ── Temporal Checks ───────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
def is_valid(self) -> bool:
|
|
413
|
+
"""Return True if this manifest is within its temporal validity window."""
|
|
414
|
+
return self._model.is_valid_now()
|
|
415
|
+
|
|
416
|
+
def is_expired(self) -> bool:
|
|
417
|
+
"""Return True if this manifest has expired."""
|
|
418
|
+
return self._model.is_expired()
|
|
419
|
+
|
|
420
|
+
# ── Properties ────────────────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
@property
|
|
423
|
+
def spiffe_id(self) -> str:
|
|
424
|
+
"""The SPIFFE ID of the agent this manifest authorizes."""
|
|
425
|
+
return self._model.agent.spiffe_id
|
|
426
|
+
|
|
427
|
+
@property
|
|
428
|
+
def capabilities_list(self) -> list[Capability]:
|
|
429
|
+
"""The list of capabilities in this manifest."""
|
|
430
|
+
return list(self._model.capabilities)
|
|
431
|
+
|
|
432
|
+
@property
|
|
433
|
+
def constraints_obj(self) -> Constraints | None:
|
|
434
|
+
"""The global constraints on this manifest, or None."""
|
|
435
|
+
return self._model.constraints
|
|
436
|
+
|
|
437
|
+
@property
|
|
438
|
+
def sponsor(self) -> str:
|
|
439
|
+
"""The human sponsor of this manifest."""
|
|
440
|
+
return self._model.human_sponsor
|
|
441
|
+
|
|
442
|
+
@property
|
|
443
|
+
def issuer(self) -> str:
|
|
444
|
+
"""The SPIFFE ID of the signing entity."""
|
|
445
|
+
return self._model.issuer
|
|
446
|
+
|
|
447
|
+
@property
|
|
448
|
+
def expires_at(self) -> datetime:
|
|
449
|
+
"""The expiration timestamp."""
|
|
450
|
+
return self._model.expires_at
|
|
451
|
+
|
|
452
|
+
@property
|
|
453
|
+
def not_before(self) -> datetime | None:
|
|
454
|
+
"""The not-before timestamp, or None."""
|
|
455
|
+
return self._model.not_before
|
|
456
|
+
|
|
457
|
+
@property
|
|
458
|
+
def delegation_chain(self) -> list[str] | None:
|
|
459
|
+
"""The delegation chain, or None if this is a root manifest."""
|
|
460
|
+
return self._model.delegation_chain
|
|
461
|
+
|
|
462
|
+
@property
|
|
463
|
+
def signature(self) -> str | None:
|
|
464
|
+
"""The base64-encoded signature, or None if unsigned."""
|
|
465
|
+
return self._model.signature
|
|
466
|
+
|
|
467
|
+
@property
|
|
468
|
+
def metadata(self) -> dict[str, Any] | None:
|
|
469
|
+
"""Implementation-specific metadata, or None."""
|
|
470
|
+
return self._model.metadata
|
|
471
|
+
|
|
472
|
+
@property
|
|
473
|
+
def acm_version(self) -> str:
|
|
474
|
+
"""The ACM protocol version."""
|
|
475
|
+
return self._model.acm_version
|
|
476
|
+
|
|
477
|
+
@property
|
|
478
|
+
def agent_model(self) -> str | None:
|
|
479
|
+
"""The LLM model name, or None."""
|
|
480
|
+
return self._model.agent.model
|
|
481
|
+
|
|
482
|
+
@property
|
|
483
|
+
def agent_provider(self) -> str | None:
|
|
484
|
+
"""The model provider, or None."""
|
|
485
|
+
return self._model.agent.provider
|
|
486
|
+
|
|
487
|
+
def __repr__(self) -> str:
|
|
488
|
+
status = "valid" if self.is_valid() else "expired"
|
|
489
|
+
caps = len(self._model.capabilities)
|
|
490
|
+
sig = "signed" if self._model.signature else "unsigned"
|
|
491
|
+
return f"ACM(spiffe_id={self.spiffe_id!r}, capabilities={caps}, status={status}, {sig})"
|
capforge/_crypto.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Cryptographic signing and verification for ACM documents.
|
|
2
|
+
|
|
3
|
+
Uses Ed25519 signatures with canonical JSON serialization.
|
|
4
|
+
The canonical form is: JSON with sorted keys, no whitespace, no signature field.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
14
|
+
from cryptography.hazmat.primitives.serialization import (
|
|
15
|
+
Encoding,
|
|
16
|
+
NoEncryption,
|
|
17
|
+
PrivateFormat,
|
|
18
|
+
PublicFormat,
|
|
19
|
+
load_pem_private_key,
|
|
20
|
+
load_pem_public_key,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
from capforge._exceptions import ACMSignatureError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def generate_private_key() -> Ed25519PrivateKey:
|
|
27
|
+
"""Generate a new Ed25519 private key."""
|
|
28
|
+
return Ed25519PrivateKey.generate()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def private_key_to_bytes(key: Ed25519PrivateKey) -> bytes:
|
|
32
|
+
"""Serialize a private key to PKCS#8 PEM bytes."""
|
|
33
|
+
return key.private_bytes(
|
|
34
|
+
encoding=Encoding.PEM,
|
|
35
|
+
format=PrivateFormat.PKCS8,
|
|
36
|
+
encryption_algorithm=NoEncryption(),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def public_key_to_bytes(key: Ed25519PrivateKey | Ed25519PublicKey) -> bytes:
|
|
41
|
+
"""Serialize a public key to SubjectPublicKeyInfo PEM bytes."""
|
|
42
|
+
pub = key.public_key() if isinstance(key, Ed25519PrivateKey) else key
|
|
43
|
+
return pub.public_bytes(
|
|
44
|
+
encoding=Encoding.PEM,
|
|
45
|
+
format=PublicFormat.SubjectPublicKeyInfo,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_private_key(path: str | Path) -> Ed25519PrivateKey:
|
|
50
|
+
"""Load an Ed25519 private key from a PEM file."""
|
|
51
|
+
data = Path(path).read_bytes()
|
|
52
|
+
key = load_pem_private_key(data, password=None)
|
|
53
|
+
if not isinstance(key, Ed25519PrivateKey):
|
|
54
|
+
msg = f"Expected Ed25519 private key, got {type(key).__name__}"
|
|
55
|
+
raise TypeError(msg)
|
|
56
|
+
return key
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_public_key(path: str | Path) -> Ed25519PublicKey:
|
|
60
|
+
"""Load an Ed25519 public key from a PEM file."""
|
|
61
|
+
data = Path(path).read_bytes()
|
|
62
|
+
key = load_pem_public_key(data)
|
|
63
|
+
if not isinstance(key, Ed25519PublicKey):
|
|
64
|
+
msg = f"Expected Ed25519 public key, got {type(key).__name__}"
|
|
65
|
+
raise TypeError(msg)
|
|
66
|
+
return key
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def canonical_json(acm_dict: dict[str, object]) -> bytes:
|
|
70
|
+
"""Serialize an ACM document to canonical JSON bytes.
|
|
71
|
+
|
|
72
|
+
The canonical form:
|
|
73
|
+
- Removes the ``signature`` field (if present).
|
|
74
|
+
- Sorts all keys lexicographically.
|
|
75
|
+
- Compact encoding (no whitespace).
|
|
76
|
+
- No trailing newline.
|
|
77
|
+
"""
|
|
78
|
+
payload = {k: v for k, v in acm_dict.items() if k != "signature"}
|
|
79
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def sign(canonical_bytes: bytes, private_key: Ed25519PrivateKey) -> str:
|
|
83
|
+
"""Sign canonical ACM bytes and return a base64-encoded signature."""
|
|
84
|
+
sig = private_key.sign(canonical_bytes)
|
|
85
|
+
return base64.b64encode(sig).decode("ascii")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def verify(canonical_bytes: bytes, signature: str, public_key: Ed25519PublicKey) -> bool:
|
|
89
|
+
"""Verify an ACM signature against canonical bytes.
|
|
90
|
+
|
|
91
|
+
Returns True if valid.
|
|
92
|
+
Raises ACMSignatureError if the signature is invalid.
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
sig_bytes = base64.b64decode(signature)
|
|
96
|
+
public_key.verify(sig_bytes, canonical_bytes)
|
|
97
|
+
return True
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
raise ACMSignatureError(f"Signature verification failed: {exc}") from exc
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def sign_dict(acm_dict: dict[str, object], private_key: Ed25519PrivateKey) -> str:
|
|
103
|
+
"""Canonicalize an ACM dict and sign it, returning the base64 signature."""
|
|
104
|
+
canon = canonical_json(acm_dict)
|
|
105
|
+
return sign(canon, private_key)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def verify_dict(
|
|
109
|
+
acm_dict: dict[str, object],
|
|
110
|
+
signature: str,
|
|
111
|
+
public_key: Ed25519PublicKey,
|
|
112
|
+
) -> bool:
|
|
113
|
+
"""Canonicalize an ACM dict and verify its signature."""
|
|
114
|
+
canon = canonical_json(acm_dict)
|
|
115
|
+
return verify(canon, signature, public_key)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def write_key_pair(
|
|
119
|
+
private_key: Ed25519PrivateKey,
|
|
120
|
+
public_key: Ed25519PublicKey | None = None,
|
|
121
|
+
path_prefix: str = "capforge-key",
|
|
122
|
+
) -> tuple[str, str]:
|
|
123
|
+
"""Write a key pair to PEM files.
|
|
124
|
+
|
|
125
|
+
Parameters
|
|
126
|
+
----------
|
|
127
|
+
private_key:
|
|
128
|
+
The private key to write.
|
|
129
|
+
public_key:
|
|
130
|
+
The public key to write. If None, it is derived from the private key.
|
|
131
|
+
path_prefix:
|
|
132
|
+
Path prefix for the output files. ``{prefix}.pem`` (private)
|
|
133
|
+
and ``{prefix}.pub`` (public) will be created.
|
|
134
|
+
|
|
135
|
+
Returns
|
|
136
|
+
-------
|
|
137
|
+
tuple[str, str]
|
|
138
|
+
(private_key_path, public_key_path)
|
|
139
|
+
"""
|
|
140
|
+
priv_path = f"{path_prefix}.pem"
|
|
141
|
+
Path(priv_path).write_bytes(private_key_to_bytes(private_key))
|
|
142
|
+
|
|
143
|
+
if public_key is None:
|
|
144
|
+
public_key = private_key.public_key()
|
|
145
|
+
|
|
146
|
+
pub_path = f"{path_prefix}.pub"
|
|
147
|
+
Path(pub_path).write_bytes(public_key_to_bytes(public_key))
|
|
148
|
+
|
|
149
|
+
return priv_path, pub_path
|