agentmesh-platform 1.0.0a1__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.
- agentmesh/__init__.py +119 -0
- agentmesh/cli/__init__.py +10 -0
- agentmesh/cli/main.py +405 -0
- agentmesh/governance/__init__.py +26 -0
- agentmesh/governance/audit.py +381 -0
- agentmesh/governance/compliance.py +447 -0
- agentmesh/governance/policy.py +385 -0
- agentmesh/governance/shadow.py +266 -0
- agentmesh/identity/__init__.py +30 -0
- agentmesh/identity/agent_id.py +319 -0
- agentmesh/identity/credentials.py +323 -0
- agentmesh/identity/delegation.py +281 -0
- agentmesh/identity/risk.py +279 -0
- agentmesh/identity/spiffe.py +230 -0
- agentmesh/identity/sponsor.py +178 -0
- agentmesh/reward/__init__.py +19 -0
- agentmesh/reward/engine.py +454 -0
- agentmesh/reward/learning.py +287 -0
- agentmesh/reward/scoring.py +203 -0
- agentmesh/trust/__init__.py +19 -0
- agentmesh/trust/bridge.py +386 -0
- agentmesh/trust/capability.py +293 -0
- agentmesh/trust/handshake.py +334 -0
- agentmesh_platform-1.0.0a1.dist-info/METADATA +332 -0
- agentmesh_platform-1.0.0a1.dist-info/RECORD +28 -0
- agentmesh_platform-1.0.0a1.dist-info/WHEEL +4 -0
- agentmesh_platform-1.0.0a1.dist-info/entry_points.txt +2 -0
- agentmesh_platform-1.0.0a1.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent Identity
|
|
3
|
+
|
|
4
|
+
Every agent gets a unique, cryptographically bound identity issued by AgentMesh CA.
|
|
5
|
+
Identity persists across restarts; revocation propagates in ≤5s.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Optional, Literal
|
|
10
|
+
from pydantic import BaseModel, Field, field_validator
|
|
11
|
+
from cryptography.hazmat.primitives import hashes
|
|
12
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
13
|
+
from cryptography.hazmat.primitives import serialization
|
|
14
|
+
import hashlib
|
|
15
|
+
import uuid
|
|
16
|
+
import base64
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AgentDID(BaseModel):
|
|
20
|
+
"""
|
|
21
|
+
Decentralized Identifier for an agent.
|
|
22
|
+
|
|
23
|
+
Format: did:mesh:<unique-id>
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
method: Literal["mesh"] = "mesh"
|
|
27
|
+
unique_id: str = Field(..., description="Unique identifier within the mesh")
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def generate(cls, name: str, org: Optional[str] = None) -> "AgentDID":
|
|
31
|
+
"""Generate a new DID for an agent."""
|
|
32
|
+
# Create deterministic but unique ID
|
|
33
|
+
seed = f"{name}:{org or 'default'}:{uuid.uuid4().hex[:8]}"
|
|
34
|
+
unique_id = hashlib.sha256(seed.encode()).hexdigest()[:32]
|
|
35
|
+
return cls(unique_id=unique_id)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_string(cls, did_string: str) -> "AgentDID":
|
|
39
|
+
"""Parse a DID string."""
|
|
40
|
+
if not did_string.startswith("did:mesh:"):
|
|
41
|
+
raise ValueError(f"Invalid AgentMesh DID: {did_string}")
|
|
42
|
+
unique_id = did_string[9:] # Remove "did:mesh:"
|
|
43
|
+
return cls(unique_id=unique_id)
|
|
44
|
+
|
|
45
|
+
def __str__(self) -> str:
|
|
46
|
+
return f"did:{self.method}:{self.unique_id}"
|
|
47
|
+
|
|
48
|
+
def __hash__(self) -> int:
|
|
49
|
+
return hash(str(self))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AgentIdentity(BaseModel):
|
|
53
|
+
"""
|
|
54
|
+
First-class identity for an AI agent.
|
|
55
|
+
|
|
56
|
+
Unlike service accounts, agent identities:
|
|
57
|
+
- Are linked to a human sponsor
|
|
58
|
+
- Have ephemeral credentials
|
|
59
|
+
- Support delegation chains
|
|
60
|
+
- Are continuously risk-scored
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
did: AgentDID = Field(..., description="Decentralized identifier")
|
|
64
|
+
name: str = Field(..., description="Human-readable agent name")
|
|
65
|
+
description: Optional[str] = Field(None, description="Agent description")
|
|
66
|
+
|
|
67
|
+
# Cryptographic identity
|
|
68
|
+
public_key: str = Field(..., description="Ed25519 public key (base64)")
|
|
69
|
+
verification_key_id: str = Field(..., description="Key ID for verification")
|
|
70
|
+
|
|
71
|
+
# Human sponsor (accountability)
|
|
72
|
+
sponsor_email: str = Field(..., description="Human sponsor email")
|
|
73
|
+
sponsor_verified: bool = Field(default=False, description="Whether sponsor is verified")
|
|
74
|
+
|
|
75
|
+
# Organization
|
|
76
|
+
organization: Optional[str] = Field(None, description="Organization name")
|
|
77
|
+
organization_id: Optional[str] = Field(None, description="Organization identifier")
|
|
78
|
+
|
|
79
|
+
# Capabilities (what this agent is allowed to do)
|
|
80
|
+
capabilities: list[str] = Field(default_factory=list)
|
|
81
|
+
|
|
82
|
+
# Metadata
|
|
83
|
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
|
84
|
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
|
85
|
+
expires_at: Optional[datetime] = Field(None, description="Identity expiration")
|
|
86
|
+
|
|
87
|
+
# Status
|
|
88
|
+
status: Literal["active", "suspended", "revoked"] = Field(default="active")
|
|
89
|
+
revocation_reason: Optional[str] = Field(None)
|
|
90
|
+
|
|
91
|
+
# Delegation
|
|
92
|
+
parent_did: Optional[str] = Field(None, description="Parent agent DID if delegated")
|
|
93
|
+
delegation_depth: int = Field(default=0, description="Depth in delegation chain")
|
|
94
|
+
|
|
95
|
+
# Private key stored separately (not serialized)
|
|
96
|
+
_private_key: Optional[ed25519.Ed25519PrivateKey] = None
|
|
97
|
+
|
|
98
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def create(
|
|
102
|
+
cls,
|
|
103
|
+
name: str,
|
|
104
|
+
sponsor: str,
|
|
105
|
+
capabilities: Optional[list[str]] = None,
|
|
106
|
+
organization: Optional[str] = None,
|
|
107
|
+
description: Optional[str] = None,
|
|
108
|
+
) -> "AgentIdentity":
|
|
109
|
+
"""
|
|
110
|
+
Create a new agent identity.
|
|
111
|
+
|
|
112
|
+
This is the primary factory method for creating governed agents.
|
|
113
|
+
"""
|
|
114
|
+
# Generate keypair
|
|
115
|
+
private_key = ed25519.Ed25519PrivateKey.generate()
|
|
116
|
+
public_key = private_key.public_key()
|
|
117
|
+
|
|
118
|
+
# Encode public key
|
|
119
|
+
public_key_bytes = public_key.public_bytes(
|
|
120
|
+
encoding=serialization.Encoding.Raw,
|
|
121
|
+
format=serialization.PublicFormat.Raw,
|
|
122
|
+
)
|
|
123
|
+
public_key_b64 = base64.b64encode(public_key_bytes).decode()
|
|
124
|
+
|
|
125
|
+
# Generate DID
|
|
126
|
+
did = AgentDID.generate(name, organization)
|
|
127
|
+
|
|
128
|
+
# Create key ID
|
|
129
|
+
key_id = f"key-{hashlib.sha256(public_key_bytes).hexdigest()[:16]}"
|
|
130
|
+
|
|
131
|
+
identity = cls(
|
|
132
|
+
did=did,
|
|
133
|
+
name=name,
|
|
134
|
+
description=description,
|
|
135
|
+
public_key=public_key_b64,
|
|
136
|
+
verification_key_id=key_id,
|
|
137
|
+
sponsor_email=sponsor,
|
|
138
|
+
organization=organization,
|
|
139
|
+
capabilities=capabilities or [],
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Store private key (not serialized)
|
|
143
|
+
identity._private_key = private_key
|
|
144
|
+
|
|
145
|
+
return identity
|
|
146
|
+
|
|
147
|
+
def sign(self, data: bytes) -> str:
|
|
148
|
+
"""Sign data with this agent's private key."""
|
|
149
|
+
if self._private_key is None:
|
|
150
|
+
raise ValueError("Private key not available for signing")
|
|
151
|
+
|
|
152
|
+
signature = self._private_key.sign(data)
|
|
153
|
+
return base64.b64encode(signature).decode()
|
|
154
|
+
|
|
155
|
+
def verify_signature(self, data: bytes, signature: str) -> bool:
|
|
156
|
+
"""Verify a signature against this agent's public key."""
|
|
157
|
+
try:
|
|
158
|
+
public_key_bytes = base64.b64decode(self.public_key)
|
|
159
|
+
public_key = ed25519.Ed25519PublicKey.from_public_bytes(public_key_bytes)
|
|
160
|
+
signature_bytes = base64.b64decode(signature)
|
|
161
|
+
public_key.verify(signature_bytes, data)
|
|
162
|
+
return True
|
|
163
|
+
except Exception:
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
def delegate(
|
|
167
|
+
self,
|
|
168
|
+
name: str,
|
|
169
|
+
capabilities: list[str],
|
|
170
|
+
description: Optional[str] = None,
|
|
171
|
+
) -> "AgentIdentity":
|
|
172
|
+
"""
|
|
173
|
+
Delegate to a child agent with narrowed capabilities.
|
|
174
|
+
|
|
175
|
+
The child agent's capabilities MUST be a subset of the parent's.
|
|
176
|
+
This is enforced cryptographically - delegation chains can only narrow.
|
|
177
|
+
"""
|
|
178
|
+
# Validate capabilities are a subset
|
|
179
|
+
for cap in capabilities:
|
|
180
|
+
if cap not in self.capabilities:
|
|
181
|
+
raise ValueError(
|
|
182
|
+
f"Cannot delegate capability '{cap}' - not in parent's capabilities"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Create child identity
|
|
186
|
+
child = AgentIdentity.create(
|
|
187
|
+
name=name,
|
|
188
|
+
sponsor=self.sponsor_email,
|
|
189
|
+
capabilities=capabilities,
|
|
190
|
+
organization=self.organization,
|
|
191
|
+
description=description,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Set delegation metadata
|
|
195
|
+
child.parent_did = str(self.did)
|
|
196
|
+
child.delegation_depth = self.delegation_depth + 1
|
|
197
|
+
|
|
198
|
+
return child
|
|
199
|
+
|
|
200
|
+
def revoke(self, reason: str) -> None:
|
|
201
|
+
"""Revoke this identity."""
|
|
202
|
+
self.status = "revoked"
|
|
203
|
+
self.revocation_reason = reason
|
|
204
|
+
self.updated_at = datetime.utcnow()
|
|
205
|
+
|
|
206
|
+
def suspend(self, reason: str) -> None:
|
|
207
|
+
"""Temporarily suspend this identity."""
|
|
208
|
+
self.status = "suspended"
|
|
209
|
+
self.revocation_reason = reason
|
|
210
|
+
self.updated_at = datetime.utcnow()
|
|
211
|
+
|
|
212
|
+
def reactivate(self) -> None:
|
|
213
|
+
"""Reactivate a suspended identity."""
|
|
214
|
+
if self.status == "revoked":
|
|
215
|
+
raise ValueError("Cannot reactivate a revoked identity")
|
|
216
|
+
self.status = "active"
|
|
217
|
+
self.revocation_reason = None
|
|
218
|
+
self.updated_at = datetime.utcnow()
|
|
219
|
+
|
|
220
|
+
def is_active(self) -> bool:
|
|
221
|
+
"""Check if identity is active and not expired."""
|
|
222
|
+
if self.status != "active":
|
|
223
|
+
return False
|
|
224
|
+
if self.expires_at and datetime.utcnow() > self.expires_at:
|
|
225
|
+
return False
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
def has_capability(self, capability: str) -> bool:
|
|
229
|
+
"""Check if this agent has a specific capability."""
|
|
230
|
+
# Support wildcard matching
|
|
231
|
+
for cap in self.capabilities:
|
|
232
|
+
if cap == "*":
|
|
233
|
+
return True
|
|
234
|
+
if cap == capability:
|
|
235
|
+
return True
|
|
236
|
+
# Support prefix matching (e.g., "read:*" matches "read:data")
|
|
237
|
+
if cap.endswith(":*"):
|
|
238
|
+
prefix = cap[:-2]
|
|
239
|
+
if capability.startswith(prefix + ":"):
|
|
240
|
+
return True
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
def to_did_document(self) -> dict:
|
|
244
|
+
"""Export as a DID Document (W3C format)."""
|
|
245
|
+
return {
|
|
246
|
+
"@context": ["https://www.w3.org/ns/did/v1"],
|
|
247
|
+
"id": str(self.did),
|
|
248
|
+
"verificationMethod": [
|
|
249
|
+
{
|
|
250
|
+
"id": f"{self.did}#{self.verification_key_id}",
|
|
251
|
+
"type": "Ed25519VerificationKey2020",
|
|
252
|
+
"controller": str(self.did),
|
|
253
|
+
"publicKeyBase64": self.public_key,
|
|
254
|
+
}
|
|
255
|
+
],
|
|
256
|
+
"authentication": [f"{self.did}#{self.verification_key_id}"],
|
|
257
|
+
"service": [
|
|
258
|
+
{
|
|
259
|
+
"id": f"{self.did}#agentmesh",
|
|
260
|
+
"type": "AgentMeshIdentity",
|
|
261
|
+
"serviceEndpoint": "https://mesh.agentmesh.dev/v1",
|
|
262
|
+
}
|
|
263
|
+
],
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class IdentityRegistry:
|
|
268
|
+
"""
|
|
269
|
+
Registry for agent identities.
|
|
270
|
+
|
|
271
|
+
In production, this would be backed by a database and the AgentMesh CA.
|
|
272
|
+
"""
|
|
273
|
+
|
|
274
|
+
def __init__(self):
|
|
275
|
+
self._identities: dict[str, AgentIdentity] = {}
|
|
276
|
+
self._by_sponsor: dict[str, list[str]] = {} # sponsor -> list of DIDs
|
|
277
|
+
|
|
278
|
+
def register(self, identity: AgentIdentity) -> None:
|
|
279
|
+
"""Register an identity."""
|
|
280
|
+
did_str = str(identity.did)
|
|
281
|
+
|
|
282
|
+
if did_str in self._identities:
|
|
283
|
+
raise ValueError(f"Identity already registered: {did_str}")
|
|
284
|
+
|
|
285
|
+
self._identities[did_str] = identity
|
|
286
|
+
|
|
287
|
+
# Index by sponsor
|
|
288
|
+
if identity.sponsor_email not in self._by_sponsor:
|
|
289
|
+
self._by_sponsor[identity.sponsor_email] = []
|
|
290
|
+
self._by_sponsor[identity.sponsor_email].append(did_str)
|
|
291
|
+
|
|
292
|
+
def get(self, did: str | AgentDID) -> Optional[AgentIdentity]:
|
|
293
|
+
"""Get an identity by DID."""
|
|
294
|
+
did_str = str(did) if isinstance(did, AgentDID) else did
|
|
295
|
+
return self._identities.get(did_str)
|
|
296
|
+
|
|
297
|
+
def revoke(self, did: str | AgentDID, reason: str) -> bool:
|
|
298
|
+
"""Revoke an identity and all its delegates."""
|
|
299
|
+
identity = self.get(did)
|
|
300
|
+
if not identity:
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
identity.revoke(reason)
|
|
304
|
+
|
|
305
|
+
# Revoke all children
|
|
306
|
+
for child_did, child in self._identities.items():
|
|
307
|
+
if child.parent_did == str(did):
|
|
308
|
+
self.revoke(child_did, f"Parent revoked: {reason}")
|
|
309
|
+
|
|
310
|
+
return True
|
|
311
|
+
|
|
312
|
+
def get_by_sponsor(self, sponsor_email: str) -> list[AgentIdentity]:
|
|
313
|
+
"""Get all identities for a sponsor."""
|
|
314
|
+
dids = self._by_sponsor.get(sponsor_email, [])
|
|
315
|
+
return [self._identities[did] for did in dids if did in self._identities]
|
|
316
|
+
|
|
317
|
+
def list_active(self) -> list[AgentIdentity]:
|
|
318
|
+
"""List all active identities."""
|
|
319
|
+
return [i for i in self._identities.values() if i.is_active()]
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ephemeral Credentials
|
|
3
|
+
|
|
4
|
+
Credentials with configurable TTL (default 15 min).
|
|
5
|
+
Expired credentials are rejected; rotation is automatic and zero-downtime.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
from typing import Optional, Literal
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
12
|
+
from cryptography.hazmat.primitives import serialization
|
|
13
|
+
import hashlib
|
|
14
|
+
import uuid
|
|
15
|
+
import base64
|
|
16
|
+
import secrets
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Credential(BaseModel):
|
|
20
|
+
"""
|
|
21
|
+
Short-lived credential for agent authentication.
|
|
22
|
+
|
|
23
|
+
Unlike long-lived service account keys:
|
|
24
|
+
- Default TTL is 15 minutes
|
|
25
|
+
- Auto-rotates before expiration
|
|
26
|
+
- Instantly revocable
|
|
27
|
+
- Capability-scoped
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
credential_id: str = Field(..., description="Unique credential identifier")
|
|
31
|
+
agent_did: str = Field(..., description="DID of the agent this credential belongs to")
|
|
32
|
+
|
|
33
|
+
# Token
|
|
34
|
+
token: str = Field(..., description="Bearer token")
|
|
35
|
+
token_hash: str = Field(..., description="SHA-256 hash of token for verification")
|
|
36
|
+
|
|
37
|
+
# Scope
|
|
38
|
+
capabilities: list[str] = Field(default_factory=list, description="Scoped capabilities")
|
|
39
|
+
resources: list[str] = Field(default_factory=list, description="Accessible resources")
|
|
40
|
+
|
|
41
|
+
# Timing
|
|
42
|
+
issued_at: datetime = Field(default_factory=datetime.utcnow)
|
|
43
|
+
expires_at: datetime = Field(..., description="When credential expires")
|
|
44
|
+
ttl_seconds: int = Field(default=900, description="TTL in seconds (default 15 min)")
|
|
45
|
+
|
|
46
|
+
# Status
|
|
47
|
+
status: Literal["active", "rotated", "revoked", "expired"] = Field(default="active")
|
|
48
|
+
revoked_at: Optional[datetime] = Field(None)
|
|
49
|
+
revocation_reason: Optional[str] = Field(None)
|
|
50
|
+
|
|
51
|
+
# Rotation
|
|
52
|
+
previous_credential_id: Optional[str] = Field(None, description="Previous credential if rotated")
|
|
53
|
+
rotation_count: int = Field(default=0)
|
|
54
|
+
|
|
55
|
+
# Context
|
|
56
|
+
issued_for: Optional[str] = Field(None, description="Purpose/context for issuance")
|
|
57
|
+
client_ip: Optional[str] = Field(None, description="IP address of requester")
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def issue(
|
|
61
|
+
cls,
|
|
62
|
+
agent_did: str,
|
|
63
|
+
capabilities: Optional[list[str]] = None,
|
|
64
|
+
resources: Optional[list[str]] = None,
|
|
65
|
+
ttl_seconds: int = 900, # 15 minutes default
|
|
66
|
+
issued_for: Optional[str] = None,
|
|
67
|
+
) -> "Credential":
|
|
68
|
+
"""
|
|
69
|
+
Issue a new credential for an agent.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
agent_did: The agent's DID
|
|
73
|
+
capabilities: Scoped capabilities (subset of agent's capabilities)
|
|
74
|
+
resources: Specific resources this credential can access
|
|
75
|
+
ttl_seconds: Time-to-live in seconds (default 15 min)
|
|
76
|
+
issued_for: Context/purpose for the credential
|
|
77
|
+
"""
|
|
78
|
+
# Generate secure token
|
|
79
|
+
token = secrets.token_urlsafe(32)
|
|
80
|
+
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
81
|
+
|
|
82
|
+
# Generate credential ID
|
|
83
|
+
credential_id = f"cred_{uuid.uuid4().hex[:16]}"
|
|
84
|
+
|
|
85
|
+
now = datetime.utcnow()
|
|
86
|
+
|
|
87
|
+
return cls(
|
|
88
|
+
credential_id=credential_id,
|
|
89
|
+
agent_did=agent_did,
|
|
90
|
+
token=token,
|
|
91
|
+
token_hash=token_hash,
|
|
92
|
+
capabilities=capabilities or [],
|
|
93
|
+
resources=resources or [],
|
|
94
|
+
issued_at=now,
|
|
95
|
+
expires_at=now + timedelta(seconds=ttl_seconds),
|
|
96
|
+
ttl_seconds=ttl_seconds,
|
|
97
|
+
issued_for=issued_for,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def is_valid(self) -> bool:
|
|
101
|
+
"""Check if credential is valid (active and not expired)."""
|
|
102
|
+
if self.status != "active":
|
|
103
|
+
return False
|
|
104
|
+
return datetime.utcnow() < self.expires_at
|
|
105
|
+
|
|
106
|
+
def is_expiring_soon(self, threshold_seconds: int = 60) -> bool:
|
|
107
|
+
"""Check if credential is about to expire."""
|
|
108
|
+
return datetime.utcnow() > (self.expires_at - timedelta(seconds=threshold_seconds))
|
|
109
|
+
|
|
110
|
+
def verify_token(self, token: str) -> bool:
|
|
111
|
+
"""Verify a token matches this credential."""
|
|
112
|
+
return hashlib.sha256(token.encode()).hexdigest() == self.token_hash
|
|
113
|
+
|
|
114
|
+
def revoke(self, reason: str) -> None:
|
|
115
|
+
"""Revoke this credential immediately."""
|
|
116
|
+
self.status = "revoked"
|
|
117
|
+
self.revoked_at = datetime.utcnow()
|
|
118
|
+
self.revocation_reason = reason
|
|
119
|
+
|
|
120
|
+
def rotate(self) -> "Credential":
|
|
121
|
+
"""
|
|
122
|
+
Rotate this credential, creating a new one.
|
|
123
|
+
|
|
124
|
+
The old credential is marked as rotated but remains valid
|
|
125
|
+
for a brief overlap period to allow zero-downtime rotation.
|
|
126
|
+
"""
|
|
127
|
+
# Mark current as rotated
|
|
128
|
+
self.status = "rotated"
|
|
129
|
+
|
|
130
|
+
# Create new credential
|
|
131
|
+
new_cred = Credential.issue(
|
|
132
|
+
agent_did=self.agent_did,
|
|
133
|
+
capabilities=self.capabilities,
|
|
134
|
+
resources=self.resources,
|
|
135
|
+
ttl_seconds=self.ttl_seconds,
|
|
136
|
+
issued_for=self.issued_for,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
new_cred.previous_credential_id = self.credential_id
|
|
140
|
+
new_cred.rotation_count = self.rotation_count + 1
|
|
141
|
+
|
|
142
|
+
return new_cred
|
|
143
|
+
|
|
144
|
+
def has_capability(self, capability: str) -> bool:
|
|
145
|
+
"""Check if this credential has a specific capability."""
|
|
146
|
+
if not self.capabilities:
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
for cap in self.capabilities:
|
|
150
|
+
if cap == "*":
|
|
151
|
+
return True
|
|
152
|
+
if cap == capability:
|
|
153
|
+
return True
|
|
154
|
+
if cap.endswith(":*"):
|
|
155
|
+
prefix = cap[:-2]
|
|
156
|
+
if capability.startswith(prefix + ":"):
|
|
157
|
+
return True
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
def can_access_resource(self, resource: str) -> bool:
|
|
161
|
+
"""Check if this credential can access a specific resource."""
|
|
162
|
+
if not self.resources:
|
|
163
|
+
return True # No resource restrictions
|
|
164
|
+
|
|
165
|
+
return resource in self.resources or "*" in self.resources
|
|
166
|
+
|
|
167
|
+
def time_remaining(self) -> timedelta:
|
|
168
|
+
"""Get time remaining until expiration."""
|
|
169
|
+
return max(timedelta(0), self.expires_at - datetime.utcnow())
|
|
170
|
+
|
|
171
|
+
def to_bearer_token(self) -> str:
|
|
172
|
+
"""Get the bearer token for Authorization header."""
|
|
173
|
+
return f"Bearer {self.token}"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class CredentialManager:
|
|
177
|
+
"""
|
|
178
|
+
Manages credential lifecycle.
|
|
179
|
+
|
|
180
|
+
Handles:
|
|
181
|
+
- Credential issuance
|
|
182
|
+
- Validation
|
|
183
|
+
- Rotation
|
|
184
|
+
- Revocation propagation
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
DEFAULT_TTL = 900 # 15 minutes
|
|
188
|
+
ROTATION_THRESHOLD = 60 # Start rotation 1 minute before expiry
|
|
189
|
+
REVOCATION_PROPAGATION_TARGET = 5 # Target: propagate in ≤5 seconds
|
|
190
|
+
|
|
191
|
+
def __init__(self, default_ttl: int = DEFAULT_TTL):
|
|
192
|
+
self.default_ttl = default_ttl
|
|
193
|
+
self._credentials: dict[str, Credential] = {}
|
|
194
|
+
self._by_agent: dict[str, list[str]] = {} # agent_did -> list of credential_ids
|
|
195
|
+
self._revocation_callbacks: list[callable] = []
|
|
196
|
+
|
|
197
|
+
def issue(
|
|
198
|
+
self,
|
|
199
|
+
agent_did: str,
|
|
200
|
+
capabilities: Optional[list[str]] = None,
|
|
201
|
+
resources: Optional[list[str]] = None,
|
|
202
|
+
ttl_seconds: Optional[int] = None,
|
|
203
|
+
issued_for: Optional[str] = None,
|
|
204
|
+
) -> Credential:
|
|
205
|
+
"""Issue a new credential."""
|
|
206
|
+
cred = Credential.issue(
|
|
207
|
+
agent_did=agent_did,
|
|
208
|
+
capabilities=capabilities,
|
|
209
|
+
resources=resources,
|
|
210
|
+
ttl_seconds=ttl_seconds or self.default_ttl,
|
|
211
|
+
issued_for=issued_for,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
self._store(cred)
|
|
215
|
+
return cred
|
|
216
|
+
|
|
217
|
+
def validate(self, token: str) -> Optional[Credential]:
|
|
218
|
+
"""
|
|
219
|
+
Validate a token and return the credential if valid.
|
|
220
|
+
|
|
221
|
+
Returns None if token is invalid, expired, or revoked.
|
|
222
|
+
"""
|
|
223
|
+
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
224
|
+
|
|
225
|
+
for cred in self._credentials.values():
|
|
226
|
+
if cred.token_hash == token_hash:
|
|
227
|
+
if cred.is_valid():
|
|
228
|
+
return cred
|
|
229
|
+
return None # Found but invalid
|
|
230
|
+
|
|
231
|
+
return None # Not found
|
|
232
|
+
|
|
233
|
+
def rotate(self, credential_id: str) -> Optional[Credential]:
|
|
234
|
+
"""Rotate a credential."""
|
|
235
|
+
cred = self._credentials.get(credential_id)
|
|
236
|
+
if not cred or not cred.is_valid():
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
new_cred = cred.rotate()
|
|
240
|
+
self._store(new_cred)
|
|
241
|
+
|
|
242
|
+
return new_cred
|
|
243
|
+
|
|
244
|
+
def rotate_if_needed(self, credential_id: str) -> Credential:
|
|
245
|
+
"""Rotate credential if it's expiring soon."""
|
|
246
|
+
cred = self._credentials.get(credential_id)
|
|
247
|
+
if not cred:
|
|
248
|
+
raise ValueError(f"Credential not found: {credential_id}")
|
|
249
|
+
|
|
250
|
+
if cred.is_expiring_soon(self.ROTATION_THRESHOLD):
|
|
251
|
+
return self.rotate(credential_id)
|
|
252
|
+
|
|
253
|
+
return cred
|
|
254
|
+
|
|
255
|
+
def revoke(self, credential_id: str, reason: str) -> bool:
|
|
256
|
+
"""
|
|
257
|
+
Revoke a credential.
|
|
258
|
+
|
|
259
|
+
Propagation target: ≤5 seconds to all systems.
|
|
260
|
+
"""
|
|
261
|
+
cred = self._credentials.get(credential_id)
|
|
262
|
+
if not cred:
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
cred.revoke(reason)
|
|
266
|
+
|
|
267
|
+
# Trigger revocation callbacks
|
|
268
|
+
for callback in self._revocation_callbacks:
|
|
269
|
+
try:
|
|
270
|
+
callback(cred)
|
|
271
|
+
except Exception:
|
|
272
|
+
pass # Don't let callback failures block revocation
|
|
273
|
+
|
|
274
|
+
return True
|
|
275
|
+
|
|
276
|
+
def revoke_all_for_agent(self, agent_did: str, reason: str) -> int:
|
|
277
|
+
"""Revoke all credentials for an agent."""
|
|
278
|
+
count = 0
|
|
279
|
+
cred_ids = self._by_agent.get(agent_did, [])
|
|
280
|
+
|
|
281
|
+
for cred_id in cred_ids:
|
|
282
|
+
if self.revoke(cred_id, reason):
|
|
283
|
+
count += 1
|
|
284
|
+
|
|
285
|
+
return count
|
|
286
|
+
|
|
287
|
+
def get_active_for_agent(self, agent_did: str) -> list[Credential]:
|
|
288
|
+
"""Get all active credentials for an agent."""
|
|
289
|
+
cred_ids = self._by_agent.get(agent_did, [])
|
|
290
|
+
return [
|
|
291
|
+
self._credentials[cid]
|
|
292
|
+
for cid in cred_ids
|
|
293
|
+
if cid in self._credentials and self._credentials[cid].is_valid()
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
def cleanup_expired(self) -> int:
|
|
297
|
+
"""Remove expired credentials from memory."""
|
|
298
|
+
expired = [
|
|
299
|
+
cid for cid, cred in self._credentials.items()
|
|
300
|
+
if not cred.is_valid() and cred.status != "active"
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
for cid in expired:
|
|
304
|
+
cred = self._credentials.pop(cid, None)
|
|
305
|
+
if cred:
|
|
306
|
+
# Remove from agent index
|
|
307
|
+
agent_creds = self._by_agent.get(cred.agent_did, [])
|
|
308
|
+
if cid in agent_creds:
|
|
309
|
+
agent_creds.remove(cid)
|
|
310
|
+
|
|
311
|
+
return len(expired)
|
|
312
|
+
|
|
313
|
+
def on_revocation(self, callback: callable) -> None:
|
|
314
|
+
"""Register a callback for revocation events."""
|
|
315
|
+
self._revocation_callbacks.append(callback)
|
|
316
|
+
|
|
317
|
+
def _store(self, cred: Credential) -> None:
|
|
318
|
+
"""Store a credential in the index."""
|
|
319
|
+
self._credentials[cred.credential_id] = cred
|
|
320
|
+
|
|
321
|
+
if cred.agent_did not in self._by_agent:
|
|
322
|
+
self._by_agent[cred.agent_did] = []
|
|
323
|
+
self._by_agent[cred.agent_did].append(cred.credential_id)
|