cred-auth 1.0.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.
- cred/__init__.py +33 -0
- cred/client.py +335 -0
- cred/exceptions.py +46 -0
- cred/identity.py +329 -0
- cred/models.py +31 -0
- cred_auth-1.0.0.dist-info/METADATA +92 -0
- cred_auth-1.0.0.dist-info/RECORD +8 -0
- cred_auth-1.0.0.dist-info/WHEEL +4 -0
cred/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Cred — credential delegation SDK for AI agents."""
|
|
2
|
+
|
|
3
|
+
from .client import AsyncCred, Cred
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
AgentRevokedException,
|
|
6
|
+
ConsentRequiredError,
|
|
7
|
+
CredError,
|
|
8
|
+
RateLimitException,
|
|
9
|
+
ScopeCeilingException,
|
|
10
|
+
)
|
|
11
|
+
from .identity import (
|
|
12
|
+
AgentIdentity,
|
|
13
|
+
CRED_PUBLIC_KEY_HEX,
|
|
14
|
+
generate_agent_identity,
|
|
15
|
+
verify_delegation_receipt,
|
|
16
|
+
)
|
|
17
|
+
from .models import DelegationResult, Connection
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Cred",
|
|
21
|
+
"AsyncCred",
|
|
22
|
+
"CredError",
|
|
23
|
+
"ConsentRequiredError",
|
|
24
|
+
"AgentRevokedException",
|
|
25
|
+
"ScopeCeilingException",
|
|
26
|
+
"RateLimitException",
|
|
27
|
+
"AgentIdentity",
|
|
28
|
+
"generate_agent_identity",
|
|
29
|
+
"verify_delegation_receipt",
|
|
30
|
+
"CRED_PUBLIC_KEY_HEX",
|
|
31
|
+
"DelegationResult",
|
|
32
|
+
"Connection",
|
|
33
|
+
]
|
cred/client.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Cred SDK — Main client classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
from urllib.parse import urlencode, urlparse
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
AgentRevokedException,
|
|
13
|
+
ConsentRequiredError,
|
|
14
|
+
CredError,
|
|
15
|
+
RateLimitException,
|
|
16
|
+
ScopeCeilingException,
|
|
17
|
+
)
|
|
18
|
+
from .models import Connection, DelegationResult
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://api.cred.ninja"
|
|
21
|
+
_LOCALHOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_expires_at(raw_value: Any, expires_in: int) -> datetime:
|
|
25
|
+
if isinstance(raw_value, str):
|
|
26
|
+
parsed_value = raw_value.replace("Z", "+00:00")
|
|
27
|
+
try:
|
|
28
|
+
return datetime.fromisoformat(parsed_value)
|
|
29
|
+
except ValueError:
|
|
30
|
+
pass
|
|
31
|
+
return datetime.now(timezone.utc) + timedelta(seconds=expires_in)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _CredBase:
|
|
35
|
+
def __init__(self, agent_token: str, base_url: str = DEFAULT_BASE_URL) -> None:
|
|
36
|
+
if not agent_token:
|
|
37
|
+
raise CredError("agent_token is required", "invalid_config", 0)
|
|
38
|
+
self._agent_token = agent_token
|
|
39
|
+
self._base_url = self._validate_base_url(base_url)
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def _validate_base_url(url: str) -> str:
|
|
43
|
+
try:
|
|
44
|
+
parsed = urlparse(url)
|
|
45
|
+
except Exception:
|
|
46
|
+
raise CredError(f'Invalid base_url: "{url}" — must be a valid HTTPS URL', "invalid_config", 0)
|
|
47
|
+
|
|
48
|
+
if parsed.scheme == "https":
|
|
49
|
+
return url.rstrip("/")
|
|
50
|
+
|
|
51
|
+
if parsed.scheme == "http" and parsed.hostname in _LOCALHOSTS:
|
|
52
|
+
return url.rstrip("/")
|
|
53
|
+
|
|
54
|
+
raise CredError(
|
|
55
|
+
"Invalid base_url: must use HTTPS except for localhost development URLs",
|
|
56
|
+
"invalid_config",
|
|
57
|
+
0,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
61
|
+
return {
|
|
62
|
+
"Authorization": f"Bearer {self._agent_token}",
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
"Accept": "application/json",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _build_delegation_result(data: dict[str, Any]) -> DelegationResult:
|
|
69
|
+
raw_expires_in = data.get("expires_in")
|
|
70
|
+
expires_in = int(raw_expires_in) if raw_expires_in is not None else 900
|
|
71
|
+
expires_at = _parse_expires_at(data.get("expires_at") or data.get("expiresAt"), expires_in)
|
|
72
|
+
return DelegationResult(
|
|
73
|
+
access_token=data["access_token"],
|
|
74
|
+
token_type=data["token_type"],
|
|
75
|
+
expires_in=expires_in,
|
|
76
|
+
expires_at=expires_at,
|
|
77
|
+
service=data["service"],
|
|
78
|
+
scopes=data["scopes"],
|
|
79
|
+
delegation_id=data["delegation_id"],
|
|
80
|
+
receipt=data.get("receipt"),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _build_connections(data: dict[str, Any]) -> list[Connection]:
|
|
85
|
+
connections = data.get("connections", [])
|
|
86
|
+
return [
|
|
87
|
+
Connection(
|
|
88
|
+
slug=connection["slug"],
|
|
89
|
+
scopes_granted=connection["scopesGranted"],
|
|
90
|
+
app_client_id=connection.get("appClientId"),
|
|
91
|
+
consented_at=connection.get("consentedAt"),
|
|
92
|
+
)
|
|
93
|
+
for connection in connections
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _error_payload(response: httpx.Response) -> tuple[dict[str, Any], str, str]:
|
|
98
|
+
body: dict[str, Any] = {}
|
|
99
|
+
try:
|
|
100
|
+
parsed = response.json()
|
|
101
|
+
if isinstance(parsed, dict):
|
|
102
|
+
body = parsed
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
message = body.get("message") or body.get("error") or f"Request failed with status {response.status_code}"
|
|
107
|
+
error_code = str(body.get("error", "unknown"))
|
|
108
|
+
return body, str(message), error_code
|
|
109
|
+
|
|
110
|
+
def _handle_response(self, response: httpx.Response) -> Any:
|
|
111
|
+
if response.status_code == 204:
|
|
112
|
+
return {}
|
|
113
|
+
|
|
114
|
+
if response.is_success:
|
|
115
|
+
return response.json()
|
|
116
|
+
|
|
117
|
+
body, message, error_code = self._error_payload(response)
|
|
118
|
+
|
|
119
|
+
if response.status_code == 403 and error_code == "consent_required":
|
|
120
|
+
raise ConsentRequiredError(message, str(body.get("consent_url", "")))
|
|
121
|
+
if response.status_code == 403 and error_code == "agent_revoked":
|
|
122
|
+
raise AgentRevokedException(message)
|
|
123
|
+
if response.status_code == 403 and error_code == "scope_ceiling_exceeded":
|
|
124
|
+
raise ScopeCeilingException(message)
|
|
125
|
+
if response.status_code == 429 and error_code == "rate_limited":
|
|
126
|
+
raise RateLimitException(message)
|
|
127
|
+
|
|
128
|
+
raise CredError(message, error_code, response.status_code)
|
|
129
|
+
|
|
130
|
+
def get_consent_url(
|
|
131
|
+
self,
|
|
132
|
+
service: str,
|
|
133
|
+
user_id: str,
|
|
134
|
+
app_client_id: str,
|
|
135
|
+
scopes: list[str],
|
|
136
|
+
redirect_uri: str,
|
|
137
|
+
) -> str:
|
|
138
|
+
params = urlencode(
|
|
139
|
+
{
|
|
140
|
+
"app_client_id": app_client_id,
|
|
141
|
+
"scopes": ",".join(scopes),
|
|
142
|
+
"redirect_uri": redirect_uri,
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
return f"{self._base_url}/api/connect/{service}/authorize?{params}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class Cred(_CredBase):
|
|
149
|
+
"""Synchronous client for the Cred credential delegation API."""
|
|
150
|
+
|
|
151
|
+
def __init__(
|
|
152
|
+
self,
|
|
153
|
+
agent_token: str,
|
|
154
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
155
|
+
) -> None:
|
|
156
|
+
super().__init__(agent_token=agent_token, base_url=base_url)
|
|
157
|
+
self._client = httpx.Client(
|
|
158
|
+
base_url=self._base_url,
|
|
159
|
+
headers=self._auth_headers(),
|
|
160
|
+
timeout=30.0,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def delegate(
|
|
164
|
+
self,
|
|
165
|
+
service: str,
|
|
166
|
+
user_id: str,
|
|
167
|
+
app_client_id: Optional[str] = None,
|
|
168
|
+
scopes: Optional[list[str]] = None,
|
|
169
|
+
agent_did: Optional[str] = None,
|
|
170
|
+
) -> DelegationResult:
|
|
171
|
+
body: dict[str, Any] = {
|
|
172
|
+
"service": service,
|
|
173
|
+
"user_id": user_id,
|
|
174
|
+
}
|
|
175
|
+
if app_client_id:
|
|
176
|
+
body["appClientId"] = app_client_id
|
|
177
|
+
if scopes:
|
|
178
|
+
body["scopes"] = scopes
|
|
179
|
+
if agent_did:
|
|
180
|
+
body["agent_did"] = agent_did
|
|
181
|
+
|
|
182
|
+
data = self._request_json("POST", "/api/v1/delegate", body)
|
|
183
|
+
return self._build_delegation_result(data)
|
|
184
|
+
|
|
185
|
+
def list_connections(
|
|
186
|
+
self,
|
|
187
|
+
user_id: str,
|
|
188
|
+
app_client_id: Optional[str] = None,
|
|
189
|
+
) -> list[dict[str, Any]]:
|
|
190
|
+
params: dict[str, str] = {"user_id": user_id}
|
|
191
|
+
if app_client_id:
|
|
192
|
+
params["app_client_id"] = app_client_id
|
|
193
|
+
|
|
194
|
+
data = self._request_json("GET", f"/api/v1/connections?{urlencode(params)}")
|
|
195
|
+
return list(data.get("connections", []))
|
|
196
|
+
|
|
197
|
+
def get_user_connections(
|
|
198
|
+
self,
|
|
199
|
+
user_id: str,
|
|
200
|
+
app_client_id: Optional[str] = None,
|
|
201
|
+
) -> list[Connection]:
|
|
202
|
+
return self._build_connections(
|
|
203
|
+
{"connections": self.list_connections(user_id, app_client_id)},
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def revoke(
|
|
207
|
+
self,
|
|
208
|
+
service: str,
|
|
209
|
+
user_id: str,
|
|
210
|
+
app_client_id: Optional[str] = None,
|
|
211
|
+
) -> None:
|
|
212
|
+
params: dict[str, str] = {"user_id": user_id}
|
|
213
|
+
if app_client_id:
|
|
214
|
+
params["app_client_id"] = app_client_id
|
|
215
|
+
|
|
216
|
+
self._request_no_content("DELETE", f"/api/v1/connections/{service}?{urlencode(params)}")
|
|
217
|
+
|
|
218
|
+
def revoke_agent(self, agent_id: str) -> None:
|
|
219
|
+
self._request_no_content("POST", f"/api/v1/agents/{agent_id}/revoke-all", {})
|
|
220
|
+
|
|
221
|
+
def _request_json(self, method: str, path: str, body: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
|
222
|
+
response = self._client.request(method, path, json=body)
|
|
223
|
+
payload = self._handle_response(response)
|
|
224
|
+
if not isinstance(payload, dict):
|
|
225
|
+
raise CredError("Expected JSON object response", "invalid_response", response.status_code)
|
|
226
|
+
return payload
|
|
227
|
+
|
|
228
|
+
def _request_no_content(self, method: str, path: str, body: Optional[dict[str, Any]] = None) -> None:
|
|
229
|
+
response = self._client.request(method, path, json=body)
|
|
230
|
+
if response.status_code != 204:
|
|
231
|
+
self._handle_response(response)
|
|
232
|
+
|
|
233
|
+
def close(self) -> None:
|
|
234
|
+
self._client.close()
|
|
235
|
+
|
|
236
|
+
def __enter__(self) -> "Cred":
|
|
237
|
+
return self
|
|
238
|
+
|
|
239
|
+
def __exit__(self, *args: Any) -> None:
|
|
240
|
+
self.close()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class AsyncCred(_CredBase):
|
|
244
|
+
"""Asynchronous client for the Cred credential delegation API."""
|
|
245
|
+
|
|
246
|
+
def __init__(
|
|
247
|
+
self,
|
|
248
|
+
agent_token: str,
|
|
249
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
250
|
+
) -> None:
|
|
251
|
+
super().__init__(agent_token=agent_token, base_url=base_url)
|
|
252
|
+
self._client = httpx.AsyncClient(
|
|
253
|
+
base_url=self._base_url,
|
|
254
|
+
headers=self._auth_headers(),
|
|
255
|
+
timeout=30.0,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
async def delegate(
|
|
259
|
+
self,
|
|
260
|
+
service: str,
|
|
261
|
+
user_id: str,
|
|
262
|
+
app_client_id: Optional[str] = None,
|
|
263
|
+
scopes: Optional[list[str]] = None,
|
|
264
|
+
agent_did: Optional[str] = None,
|
|
265
|
+
) -> DelegationResult:
|
|
266
|
+
body: dict[str, Any] = {
|
|
267
|
+
"service": service,
|
|
268
|
+
"user_id": user_id,
|
|
269
|
+
}
|
|
270
|
+
if app_client_id:
|
|
271
|
+
body["appClientId"] = app_client_id
|
|
272
|
+
if scopes:
|
|
273
|
+
body["scopes"] = scopes
|
|
274
|
+
if agent_did:
|
|
275
|
+
body["agent_did"] = agent_did
|
|
276
|
+
|
|
277
|
+
data = await self._request_json("POST", "/api/v1/delegate", body)
|
|
278
|
+
return self._build_delegation_result(data)
|
|
279
|
+
|
|
280
|
+
async def list_connections(
|
|
281
|
+
self,
|
|
282
|
+
user_id: str,
|
|
283
|
+
app_client_id: Optional[str] = None,
|
|
284
|
+
) -> list[dict[str, Any]]:
|
|
285
|
+
params: dict[str, str] = {"user_id": user_id}
|
|
286
|
+
if app_client_id:
|
|
287
|
+
params["app_client_id"] = app_client_id
|
|
288
|
+
|
|
289
|
+
data = await self._request_json("GET", f"/api/v1/connections?{urlencode(params)}")
|
|
290
|
+
return list(data.get("connections", []))
|
|
291
|
+
|
|
292
|
+
async def get_user_connections(
|
|
293
|
+
self,
|
|
294
|
+
user_id: str,
|
|
295
|
+
app_client_id: Optional[str] = None,
|
|
296
|
+
) -> list[Connection]:
|
|
297
|
+
return self._build_connections(
|
|
298
|
+
{"connections": await self.list_connections(user_id, app_client_id)},
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
async def revoke(
|
|
302
|
+
self,
|
|
303
|
+
service: str,
|
|
304
|
+
user_id: str,
|
|
305
|
+
app_client_id: Optional[str] = None,
|
|
306
|
+
) -> None:
|
|
307
|
+
params: dict[str, str] = {"user_id": user_id}
|
|
308
|
+
if app_client_id:
|
|
309
|
+
params["app_client_id"] = app_client_id
|
|
310
|
+
|
|
311
|
+
await self._request_no_content("DELETE", f"/api/v1/connections/{service}?{urlencode(params)}")
|
|
312
|
+
|
|
313
|
+
async def revoke_agent(self, agent_id: str) -> None:
|
|
314
|
+
await self._request_no_content("POST", f"/api/v1/agents/{agent_id}/revoke-all", {})
|
|
315
|
+
|
|
316
|
+
async def _request_json(self, method: str, path: str, body: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
|
317
|
+
response = await self._client.request(method, path, json=body)
|
|
318
|
+
payload = self._handle_response(response)
|
|
319
|
+
if not isinstance(payload, dict):
|
|
320
|
+
raise CredError("Expected JSON object response", "invalid_response", response.status_code)
|
|
321
|
+
return payload
|
|
322
|
+
|
|
323
|
+
async def _request_no_content(self, method: str, path: str, body: Optional[dict[str, Any]] = None) -> None:
|
|
324
|
+
response = await self._client.request(method, path, json=body)
|
|
325
|
+
if response.status_code != 204:
|
|
326
|
+
self._handle_response(response)
|
|
327
|
+
|
|
328
|
+
async def close(self) -> None:
|
|
329
|
+
await self._client.aclose()
|
|
330
|
+
|
|
331
|
+
async def __aenter__(self) -> "AsyncCred":
|
|
332
|
+
return self
|
|
333
|
+
|
|
334
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
335
|
+
await self.close()
|
cred/exceptions.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Cred SDK — Exception classes."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CredError(Exception):
|
|
5
|
+
"""Base exception for all Cred API errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, code: str, status_code: int) -> None:
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.code = code
|
|
10
|
+
self.status_code = status_code
|
|
11
|
+
|
|
12
|
+
def __repr__(self) -> str:
|
|
13
|
+
return f"{self.__class__.__name__}(message={str(self)!r}, code={self.code!r}, status_code={self.status_code})"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConsentRequiredError(CredError):
|
|
17
|
+
"""Raised when a user has not yet consented to a service.
|
|
18
|
+
|
|
19
|
+
The ``consent_url`` attribute contains the URL to redirect the user to
|
|
20
|
+
complete the OAuth consent flow.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message: str, consent_url: str) -> None:
|
|
24
|
+
super().__init__(message, "consent_required", 403)
|
|
25
|
+
self.consent_url = consent_url
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AgentRevokedException(CredError):
|
|
29
|
+
"""Raised when the agent token has been revoked server-side."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str) -> None:
|
|
32
|
+
super().__init__(message, "agent_revoked", 403)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ScopeCeilingException(CredError):
|
|
36
|
+
"""Raised when requested scopes exceed the agent's ceiling."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, message: str) -> None:
|
|
39
|
+
super().__init__(message, "scope_ceiling_exceeded", 403)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RateLimitException(CredError):
|
|
43
|
+
"""Raised when the API rate limits the request."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, message: str) -> None:
|
|
46
|
+
super().__init__(message, "rate_limited", 429)
|
cred/identity.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Agent Identity — DID:key generation and management.
|
|
2
|
+
|
|
3
|
+
Uses Ed25519 key pairs with did:key encoding (RFC draft-multiformats-multibase).
|
|
4
|
+
Uses `cryptography` package for Ed25519 operations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
14
|
+
Ed25519PrivateKey,
|
|
15
|
+
Ed25519PublicKey,
|
|
16
|
+
)
|
|
17
|
+
from cryptography.hazmat.primitives import serialization
|
|
18
|
+
|
|
19
|
+
# ── Cred Public Key (pinned at build time) ───────────────────────────────────
|
|
20
|
+
|
|
21
|
+
CRED_PUBLIC_KEY_HEX = "ed50f12caeed3e3054be071c2884fda47752a02b5d98e37dfd79db5fd502699f"
|
|
22
|
+
|
|
23
|
+
# ── Base58btc alphabet (Bitcoin) ─────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
_BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _encode_base58(data: bytes) -> str:
|
|
29
|
+
"""Encode bytes to base58btc string."""
|
|
30
|
+
if len(data) == 0:
|
|
31
|
+
return ""
|
|
32
|
+
|
|
33
|
+
# Count leading zeros
|
|
34
|
+
zeros = 0
|
|
35
|
+
for byte in data:
|
|
36
|
+
if byte != 0:
|
|
37
|
+
break
|
|
38
|
+
zeros += 1
|
|
39
|
+
|
|
40
|
+
# Convert to big integer and repeatedly divide by 58
|
|
41
|
+
size = (len(data) * 138 // 100) + 1
|
|
42
|
+
b58 = [0] * size
|
|
43
|
+
length = 0
|
|
44
|
+
|
|
45
|
+
for byte in data:
|
|
46
|
+
carry = byte
|
|
47
|
+
i = 0
|
|
48
|
+
for j in range(size - 1, -1, -1):
|
|
49
|
+
if carry == 0 and i >= length:
|
|
50
|
+
break
|
|
51
|
+
carry += 256 * b58[j]
|
|
52
|
+
b58[j] = carry % 58
|
|
53
|
+
carry //= 58
|
|
54
|
+
i += 1
|
|
55
|
+
length = i
|
|
56
|
+
|
|
57
|
+
# Skip leading zeros in base58 result
|
|
58
|
+
start = size - length
|
|
59
|
+
while start < size and b58[start] == 0:
|
|
60
|
+
start += 1
|
|
61
|
+
|
|
62
|
+
# Build result string
|
|
63
|
+
result = "1" * zeros
|
|
64
|
+
for i in range(start, size):
|
|
65
|
+
result += _BASE58_ALPHABET[b58[i]]
|
|
66
|
+
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _decode_base58(s: str) -> bytes:
|
|
71
|
+
"""Decode base58btc string to bytes."""
|
|
72
|
+
if len(s) == 0:
|
|
73
|
+
return b""
|
|
74
|
+
|
|
75
|
+
# Build alphabet lookup
|
|
76
|
+
lookup = {char: i for i, char in enumerate(_BASE58_ALPHABET)}
|
|
77
|
+
|
|
78
|
+
# Count leading '1's (zeros)
|
|
79
|
+
zeros = 0
|
|
80
|
+
for char in s:
|
|
81
|
+
if char != "1":
|
|
82
|
+
break
|
|
83
|
+
zeros += 1
|
|
84
|
+
|
|
85
|
+
# Decode to bytes
|
|
86
|
+
size = (len(s) * 733 // 1000) + 1
|
|
87
|
+
result = [0] * size
|
|
88
|
+
length = 0
|
|
89
|
+
|
|
90
|
+
for char in s:
|
|
91
|
+
value = lookup.get(char)
|
|
92
|
+
if value is None:
|
|
93
|
+
raise ValueError(f"Invalid base58 character: {char}")
|
|
94
|
+
|
|
95
|
+
carry = value
|
|
96
|
+
i = 0
|
|
97
|
+
for j in range(size - 1, -1, -1):
|
|
98
|
+
if carry == 0 and i >= length:
|
|
99
|
+
break
|
|
100
|
+
carry += 58 * result[j]
|
|
101
|
+
result[j] = carry % 256
|
|
102
|
+
carry //= 256
|
|
103
|
+
i += 1
|
|
104
|
+
length = i
|
|
105
|
+
|
|
106
|
+
# Skip leading zeros in byte array
|
|
107
|
+
start = size - length
|
|
108
|
+
while start < size and result[start] == 0:
|
|
109
|
+
start += 1
|
|
110
|
+
|
|
111
|
+
# Prepend zeros and return
|
|
112
|
+
return bytes([0] * zeros + result[start:])
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ── Ed25519 multicodec prefix ────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
# did:key multicodec prefix for Ed25519 public keys: 0xed01
|
|
118
|
+
# Encoded as varint: [0xed, 0x01]
|
|
119
|
+
_ED25519_MULTICODEC_PREFIX = bytes([0xED, 0x01])
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── Types ────────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class ExportedIdentity:
|
|
127
|
+
"""Exported identity for persistence."""
|
|
128
|
+
|
|
129
|
+
did: str
|
|
130
|
+
private_key_hex: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class AgentIdentity:
|
|
134
|
+
"""Agent identity with Ed25519 key pair and DID.
|
|
135
|
+
|
|
136
|
+
Attributes:
|
|
137
|
+
did: DID in did:key format: did:key:z6Mk<base58-encoded-public-key>
|
|
138
|
+
public_key: Raw 32-byte Ed25519 public key
|
|
139
|
+
private_key: Raw 32-byte Ed25519 private key
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
did: str,
|
|
145
|
+
public_key: bytes,
|
|
146
|
+
private_key: bytes,
|
|
147
|
+
) -> None:
|
|
148
|
+
self._did = did
|
|
149
|
+
self._public_key = bytes(public_key)
|
|
150
|
+
self._private_key = bytes(private_key)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def did(self) -> str:
|
|
154
|
+
return self._did
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def public_key(self) -> bytes:
|
|
158
|
+
return bytes(self._public_key)
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def private_key(self) -> bytes:
|
|
162
|
+
return bytes(self._private_key)
|
|
163
|
+
|
|
164
|
+
def export(self) -> dict[str, str]:
|
|
165
|
+
"""Export identity for persistence.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Dict with 'did' and 'private_key_hex' keys.
|
|
169
|
+
"""
|
|
170
|
+
return {
|
|
171
|
+
"did": self._did,
|
|
172
|
+
"private_key_hex": self._private_key.hex(),
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def from_export(cls, data: dict[str, str]) -> "AgentIdentity":
|
|
177
|
+
"""Import a persisted agent identity.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
data: Dict with 'did' and 'private_key_hex' keys.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
AgentIdentity with full key pair.
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
ValueError: If DID format is invalid or private key is wrong size.
|
|
187
|
+
"""
|
|
188
|
+
did = data["did"]
|
|
189
|
+
private_key_hex = data["private_key_hex"]
|
|
190
|
+
|
|
191
|
+
# Validate DID format
|
|
192
|
+
if not did.startswith("did:key:z"):
|
|
193
|
+
raise ValueError("Invalid DID format: must start with did:key:z")
|
|
194
|
+
|
|
195
|
+
# Decode private key from hex
|
|
196
|
+
private_key_bytes = bytes.fromhex(private_key_hex)
|
|
197
|
+
if len(private_key_bytes) != 32:
|
|
198
|
+
raise ValueError("Invalid private key: must be 32 bytes")
|
|
199
|
+
|
|
200
|
+
# Derive public key from private key using cryptography
|
|
201
|
+
private_key_obj = Ed25519PrivateKey.from_private_bytes(private_key_bytes)
|
|
202
|
+
public_key_obj = private_key_obj.public_key()
|
|
203
|
+
public_key_bytes = public_key_obj.public_bytes(
|
|
204
|
+
encoding=serialization.Encoding.Raw,
|
|
205
|
+
format=serialization.PublicFormat.Raw,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Verify DID matches derived public key
|
|
209
|
+
derived_did = _public_key_to_did(public_key_bytes)
|
|
210
|
+
if derived_did != did:
|
|
211
|
+
raise ValueError("DID does not match derived public key")
|
|
212
|
+
|
|
213
|
+
return cls(did, public_key_bytes, private_key_bytes)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _public_key_to_did(public_key: bytes) -> str:
|
|
217
|
+
"""Convert raw Ed25519 public key to did:key format."""
|
|
218
|
+
# Prepend multicodec prefix and encode as base58btc
|
|
219
|
+
prefixed = _ED25519_MULTICODEC_PREFIX + public_key
|
|
220
|
+
# 'z' is the multibase prefix for base58btc
|
|
221
|
+
return f"did:key:z{_encode_base58(prefixed)}"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def generate_agent_identity() -> AgentIdentity:
|
|
225
|
+
"""Generate a new agent identity with Ed25519 key pair.
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
AgentIdentity with DID, public key, private key, and export function.
|
|
229
|
+
"""
|
|
230
|
+
# Generate new Ed25519 key pair
|
|
231
|
+
private_key_obj = Ed25519PrivateKey.generate()
|
|
232
|
+
public_key_obj = private_key_obj.public_key()
|
|
233
|
+
|
|
234
|
+
# Extract raw key bytes
|
|
235
|
+
private_key_bytes = private_key_obj.private_bytes(
|
|
236
|
+
encoding=serialization.Encoding.Raw,
|
|
237
|
+
format=serialization.PrivateFormat.Raw,
|
|
238
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
239
|
+
)
|
|
240
|
+
public_key_bytes = public_key_obj.public_bytes(
|
|
241
|
+
encoding=serialization.Encoding.Raw,
|
|
242
|
+
format=serialization.PublicFormat.Raw,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Build did:key from public key
|
|
246
|
+
did = _public_key_to_did(public_key_bytes)
|
|
247
|
+
|
|
248
|
+
return AgentIdentity(did, public_key_bytes, private_key_bytes)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ── Receipt Verification ─────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _base64url_decode(s: str) -> bytes:
|
|
255
|
+
"""Decode base64url string to bytes."""
|
|
256
|
+
import base64
|
|
257
|
+
|
|
258
|
+
# Replace URL-safe characters and add padding
|
|
259
|
+
s = s.replace("-", "+").replace("_", "/")
|
|
260
|
+
# Add padding
|
|
261
|
+
padding = 4 - (len(s) % 4)
|
|
262
|
+
if padding != 4:
|
|
263
|
+
s += "=" * padding
|
|
264
|
+
return base64.b64decode(s)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def verify_delegation_receipt(
|
|
268
|
+
receipt: Optional[str],
|
|
269
|
+
expected_did: str,
|
|
270
|
+
cred_public_key: Optional[str] = None,
|
|
271
|
+
) -> bool:
|
|
272
|
+
"""Verify a delegation receipt from the Cred API.
|
|
273
|
+
|
|
274
|
+
Receipts are JWS compact serialization (header.payload.signature) signed
|
|
275
|
+
by Cred's Ed25519 key. Returns True if signature is valid and DID matches.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
receipt: JWS compact serialization string (or None).
|
|
279
|
+
expected_did: Expected agent DID (sub claim in receipt payload).
|
|
280
|
+
cred_public_key: Cred's public key in hex format (defaults to CRED_PUBLIC_KEY_HEX).
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
True if receipt is valid and matches expected DID, False otherwise.
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
ValueError: If CRED_PUBLIC_KEY_HEX is still placeholder.
|
|
287
|
+
"""
|
|
288
|
+
# Return False for missing receipts (don't throw)
|
|
289
|
+
if not receipt:
|
|
290
|
+
return False
|
|
291
|
+
|
|
292
|
+
cred_public_key_hex = cred_public_key or CRED_PUBLIC_KEY_HEX
|
|
293
|
+
|
|
294
|
+
# Placeholder check — can't verify without real key
|
|
295
|
+
if cred_public_key_hex == "PLACEHOLDER_REPLACE_BEFORE_LAUNCH":
|
|
296
|
+
raise ValueError("CRED_PUBLIC_KEY_HEX is placeholder — cannot verify receipts")
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
# Parse JWS compact serialization: header.payload.signature
|
|
300
|
+
parts = receipt.split(".")
|
|
301
|
+
if len(parts) != 3:
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
header_b64, payload_b64, signature_b64 = parts
|
|
305
|
+
|
|
306
|
+
# Decode and validate header
|
|
307
|
+
header = json.loads(_base64url_decode(header_b64).decode("utf-8"))
|
|
308
|
+
if header.get("alg") != "EdDSA" or header.get("typ") != "JWT":
|
|
309
|
+
return False
|
|
310
|
+
|
|
311
|
+
# Decode payload and verify DID matches
|
|
312
|
+
payload = json.loads(_base64url_decode(payload_b64).decode("utf-8"))
|
|
313
|
+
if payload.get("sub") != expected_did:
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
# Verify signature
|
|
317
|
+
signature_input = f"{header_b64}.{payload_b64}".encode("utf-8")
|
|
318
|
+
signature = _base64url_decode(signature_b64)
|
|
319
|
+
|
|
320
|
+
# Build Ed25519 public key object from raw bytes
|
|
321
|
+
cred_public_key_bytes = bytes.fromhex(cred_public_key_hex)
|
|
322
|
+
if len(cred_public_key_bytes) != 32:
|
|
323
|
+
return False
|
|
324
|
+
|
|
325
|
+
public_key_obj = Ed25519PublicKey.from_public_bytes(cred_public_key_bytes)
|
|
326
|
+
public_key_obj.verify(signature, signature_input)
|
|
327
|
+
return True
|
|
328
|
+
except Exception:
|
|
329
|
+
return False
|
cred/models.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Cred SDK — Response model dataclasses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class DelegationResult:
|
|
12
|
+
"""Result of a successful delegation request."""
|
|
13
|
+
|
|
14
|
+
access_token: str
|
|
15
|
+
token_type: str
|
|
16
|
+
expires_in: int
|
|
17
|
+
expires_at: datetime
|
|
18
|
+
service: str
|
|
19
|
+
scopes: list[str]
|
|
20
|
+
delegation_id: str
|
|
21
|
+
receipt: Optional[str] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Connection:
|
|
26
|
+
"""A user's active service connection."""
|
|
27
|
+
|
|
28
|
+
slug: str
|
|
29
|
+
scopes_granted: list[str]
|
|
30
|
+
app_client_id: Optional[str] = None
|
|
31
|
+
consented_at: Optional[str] = None
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cred-auth
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Cred — credential delegation SDK for AI agents
|
|
5
|
+
Project-URL: Homepage, https://cred.ninja
|
|
6
|
+
Project-URL: Repository, https://github.com/cred-ninja/sdk
|
|
7
|
+
Project-URL: Documentation, https://docs.cred.ninja
|
|
8
|
+
Author-email: Cred <hello@cred.ninja>
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
Keywords: ai-agents,cred,credentials,delegation,oauth
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: cryptography>=42.0
|
|
22
|
+
Requires-Dist: httpx>=0.24
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: hatchling; extra == 'dev'
|
|
25
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: respx>=0.20; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# cred-auth
|
|
31
|
+
|
|
32
|
+
Python SDK for Cred. OAuth2 credential delegation for AI agents. Tokens are brokered, never exposed.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install cred-auth
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import os
|
|
44
|
+
from cred import Cred, ConsentRequiredError
|
|
45
|
+
|
|
46
|
+
cred = Cred(agent_token=os.environ["CRED_AGENT_TOKEN"])
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
result = cred.delegate(
|
|
50
|
+
service="google",
|
|
51
|
+
user_id="user_123",
|
|
52
|
+
app_client_id="my_app_client_id",
|
|
53
|
+
scopes=["calendar.readonly"],
|
|
54
|
+
)
|
|
55
|
+
print(result.access_token)
|
|
56
|
+
except ConsentRequiredError as e:
|
|
57
|
+
# Redirect user to e.consent_url to complete OAuth consent
|
|
58
|
+
print(f"Redirect user to: {e.consent_url}")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## API
|
|
62
|
+
|
|
63
|
+
### `Cred(agent_token, base_url=...)`
|
|
64
|
+
|
|
65
|
+
### `delegate(service, user_id, app_client_id, scopes=None) → DelegationResult`
|
|
66
|
+
|
|
67
|
+
Get a delegated access token for a service on behalf of a user.
|
|
68
|
+
|
|
69
|
+
Raises `ConsentRequiredError` (with `.consent_url`) if the user hasn't connected the service.
|
|
70
|
+
|
|
71
|
+
### `get_user_connections(user_id, app_client_id=None) → list[Connection]`
|
|
72
|
+
|
|
73
|
+
List all active service connections for a user.
|
|
74
|
+
|
|
75
|
+
### `get_consent_url(service, user_id, app_client_id, scopes, redirect_uri) → str`
|
|
76
|
+
|
|
77
|
+
Build a consent URL. Pure URL construction, no HTTP call.
|
|
78
|
+
|
|
79
|
+
### `revoke(service, user_id, app_client_id=None) → None`
|
|
80
|
+
|
|
81
|
+
Revoke a user's connection to a service.
|
|
82
|
+
|
|
83
|
+
## Context Manager
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
with Cred(agent_token=token) as cred:
|
|
87
|
+
result = cred.delegate(service="github", user_id="u1", app_client_id="app1")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Self-Hosted Server Mode
|
|
91
|
+
|
|
92
|
+
Point `base_url` at your own `@credninja/server` deployment when you want a separate credential broker instead of local-only usage.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
cred/__init__.py,sha256=detfphK_u0GBcnE2qyxcZU9Au1qO3nej76PYEoUB4zs,746
|
|
2
|
+
cred/client.py,sha256=6x4vqGXUhy-B8A4Q4iV-hWRYyevq-ho0V1SRjvkwWnU,11293
|
|
3
|
+
cred/exceptions.py,sha256=_U64mjaMAY6q5PxyO5-ud0xOJ11N6IqddoS-suXc3vY,1460
|
|
4
|
+
cred/identity.py,sha256=ka9ZV8fNrQJtO1vJ6tev6Ltg7KFRU74OKLOCFFrZmg0,10240
|
|
5
|
+
cred/models.py,sha256=i2O6SQRkpq5EhXvzW5rA8p8-Q4UF2cUjeu5yCsngPwc,651
|
|
6
|
+
cred_auth-1.0.0.dist-info/METADATA,sha256=3w3yui_PHY081tC7TMSyhdbiZWHTVbHrqS9wOLFwPZY,2780
|
|
7
|
+
cred_auth-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
cred_auth-1.0.0.dist-info/RECORD,,
|