chore(python): audit core package

_client_common.py owns request building and EOR signing for both clients; types follow the Backend contract; the deprecated issue_token alias and token response aliases are removed; crypto, utils, and CLI comments trimmed.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
This commit is contained in:
雷电芽衣 committed 2026-09-02 23:05:37 -04:00
1 parent 54e65eb56a
commit 0f72a5e2f1
10 files changed
+414 -706

No files matched your search

+2 -2
View File
@@ -168,7 +168,7 @@ auth = ElydoraClient.login(base_url, email, password)
me = client.get_me()
# Issue a new API token (with optional TTL in seconds)
token_resp = client.issue_token(ttl_seconds=3600)
token_resp = client.issue_api_token(ttl_seconds=3600)
```
### Operations
@@ -320,7 +320,7 @@ from elydora import (
VerifyOperationResponse, AuditQueryResponse, GetEpochResponse,
ListEpochsResponse, CreateExportResponse, GetExportResponse,
ListExportsResponse, JWKSResponse, AuthRegisterResponse,
AuthLoginResponse, GetMeResponse, IssueTokenResponse, HealthResponse,
AuthLoginResponse, GetMeResponse, IssueApiTokenResponse, HealthResponse,
# Request types
RegisterAgentRequest,
+8 -8
View File
@@ -21,6 +21,10 @@ from .types import (
AgentKey,
AgentAssignment,
AdminEvent,
AuthMeUser,
CurrentOrganization,
DependencyHealth,
ErrorCode,
EpochAnchor,
MemberEntry,
Operation,
@@ -65,8 +69,6 @@ from .types import (
GetMeResponse,
IssueApiTokenResponse,
RotateApiTokenResponse,
IssueTokenResponse,
RotateTokenResponse,
HealthResponse,
DeepHealthResponse,
)
@@ -87,7 +89,6 @@ __all__ = [
"base64url_decode",
"generate_nonce",
"generate_uuidv7",
# Literal type aliases
"AdminAction",
"AgentStatus",
"ExportStatus",
@@ -95,13 +96,16 @@ __all__ = [
"IntegrationType",
"KeyStatus",
"RbacRole",
# Entity types
"EOR",
"EAR",
"Agent",
"AgentAssignment",
"AgentKey",
"AdminEvent",
"AuthMeUser",
"CurrentOrganization",
"DependencyHealth",
"ErrorCode",
"EpochAnchor",
"MemberEntry",
"Operation",
@@ -111,10 +115,8 @@ __all__ = [
"Organization",
"User",
"WebhookEntry",
# Request types
"RegisterAgentRequest",
"UpdateAgentRequest",
# Response types
"RegisterAgentResponse",
"UpdateAgentResponse",
"GetAgentResponse",
@@ -141,8 +143,6 @@ __all__ = [
"GetMeResponse",
"IssueApiTokenResponse",
"RotateApiTokenResponse",
"IssueTokenResponse",
"RotateTokenResponse",
"HealthResponse",
"DeepHealthResponse",
]
+115
View File
@@ -0,0 +1,115 @@
"""Request and response building shared by the sync and async clients."""
from __future__ import annotations
import time
from typing import Any, Dict, Optional, Tuple, Union
from .crypto import compute_chain_hash, compute_payload_hash, sign_eor
from .errors import ElydoraError
from .types import EOR
from .utils import generate_nonce, generate_uuidv7
# 32 zero bytes; equals the Backend GENESIS_CHAIN_HASH.
GENESIS_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
DEFAULT_BASE_URL = "https://api.elydora.com"
REQUEST_TIMEOUT_SECONDS = 30
def build_headers(token: Optional[str]) -> Dict[str, str]:
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def error_from_response(status_code: int, body: Any, text: str) -> ElydoraError:
error = body.get("error", {}) if isinstance(body, dict) else {}
return ElydoraError(
code=error.get("code", "INTERNAL_ERROR"),
message=error.get("message") or text or "Unknown error",
request_id=error.get("request_id", ""),
details=error.get("details"),
status_code=status_code,
)
def without_none(values: Dict[str, Any]) -> Dict[str, Any]:
return {key: value for key, value in values.items() if value is not None}
def register_body(
email: str,
password: str,
display_name: Optional[str],
org_name: Optional[str],
) -> Dict[str, Any]:
return without_none({
"email": email,
"password": password,
"display_name": display_name,
"org_name": org_name,
})
def api_token_body(ttl_seconds: Optional[int]) -> Dict[str, Any]:
return without_none({"ttl_seconds": ttl_seconds})
def export_body(
start_time: int,
end_time: int,
format: str,
agent_id: Optional[str],
operation_type: Optional[str],
) -> Dict[str, Any]:
return without_none({
"start_time": start_time,
"end_time": end_time,
"format": format,
"agent_id": agent_id,
"operation_type": operation_type,
})
def admin_events_params(limit: Optional[int]) -> Dict[str, str]:
return {} if limit is None else {"limit": str(limit)}
def build_eor(
*,
org_id: str,
agent_id: str,
private_key: str,
kid: str,
ttl_ms: int,
prev_chain_hash: str,
operation_type: str,
subject: Dict[str, Any],
action: Dict[str, Any],
payload: Union[Dict[str, Any], str, None],
) -> Tuple[EOR, str]:
"""Build and sign one EOR; returns the record and its chain hash."""
operation_id = generate_uuidv7()
issued_at = int(time.time() * 1000)
payload_hash = compute_payload_hash(payload)
chain_hash = compute_chain_hash(prev_chain_hash, payload_hash, operation_id, issued_at)
eor: Dict[str, Any] = {
"op_version": "1.0",
"operation_id": operation_id,
"org_id": org_id,
"agent_id": agent_id,
"issued_at": issued_at,
"ttl_ms": ttl_ms,
"nonce": generate_nonce(),
"operation_type": operation_type,
"subject": subject,
"action": action,
"payload": payload,
"payload_hash": payload_hash,
"prev_chain_hash": prev_chain_hash,
"agent_pubkey_kid": kid,
"signature": "",
}
eor["signature"] = sign_eor(eor, private_key)
return eor, chain_hash # type: ignore[return-value]
+113 -254
View File
@@ -2,16 +2,26 @@
from __future__ import annotations
import time
import warnings
from typing import Any, Dict, Optional, Union
from typing import Any, Dict, List, Optional, Union
import aiohttp
from ._async_http import request_with_retries
from ._client_common import (
DEFAULT_BASE_URL,
GENESIS_CHAIN_HASH,
REQUEST_TIMEOUT_SECONDS,
admin_events_params,
api_token_body,
build_eor,
build_headers,
error_from_response,
export_body,
register_body,
without_none,
)
from ._retry import require_max_retries
from .crypto import compute_chain_hash, compute_payload_hash, sign_eor
from .errors import ElydoraError
from .integration_types import require_integration_type
from .types import (
AuditQueryResponse,
@@ -21,35 +31,33 @@ from .types import (
DeepHealthResponse,
DeleteAgentResponse,
EOR,
FreezeAgentResponse,
GetAgentResponse,
GetEpochResponse,
GetExportResponse,
GetMeResponse,
GetOperationResponse,
HealthResponse,
IssueApiTokenResponse,
IntegrationType,
ListAdminEventsResponse,
ListMembersResponse,
ListWebhooksResponse,
RotateApiTokenResponse,
IssueApiTokenResponse,
JWKSResponse,
ListAdminEventsResponse,
ListAgentsResponse,
ListEpochsResponse,
ListExportsResponse,
ListMembersResponse,
ListWebhooksResponse,
RegisterAgentRequest,
RegisterAgentResponse,
RegisterWebhookResponse,
RotateApiTokenResponse,
SubmitOperationResponse,
UnfreezeAgentResponse,
UpdateAgentResponse,
VerifyOperationResponse,
)
from .utils import generate_nonce, generate_uuidv7
# The genesis chain hash: base64url encoding of 32 zero bytes.
# Must match the backend's GENESIS_CHAIN_HASH constant exactly.
GENESIS_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
TIMEOUT = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT_SECONDS)
class AsyncElydoraClient:
@@ -61,7 +69,7 @@ class AsyncElydoraClient:
private_key: Base64url-encoded 32-byte Ed25519 private key seed.
base_url: API base URL.
ttl_ms: Time-to-live for operations in milliseconds.
max_retries: Maximum number of retries on transient failures.
max_retries: Retries after the initial attempt on transient failures.
token: Optional API token for authenticated endpoints.
"""
@@ -71,7 +79,7 @@ class AsyncElydoraClient:
agent_id: str,
private_key: str,
*,
base_url: str = "https://api.elydora.com",
base_url: str = DEFAULT_BASE_URL,
ttl_ms: int = 30000,
max_retries: int = 3,
token: Optional[str] = None,
@@ -83,17 +91,14 @@ class AsyncElydoraClient:
self.ttl_ms = ttl_ms
self.max_retries = require_max_retries(max_retries)
self.token = token
self._prev_chain_hash = GENESIS_CHAIN_HASH
self._kid = agent_id + '-key-1'
self._kid = agent_id + "-key-1"
self._session: Optional[aiohttp.ClientSession] = None
def set_kid(self, kid: str) -> None:
"""Set the key ID used for signing operations."""
self._kid = kid
def set_prev_chain_hash(self, prev_chain_hash: str) -> None:
"""Set the previous chain hash for the next operation."""
self._prev_chain_hash = prev_chain_hash
async def _get_session(self) -> aiohttp.ClientSession:
@@ -102,20 +107,9 @@ class AsyncElydoraClient:
return self._session
async def close(self) -> None:
"""Close the underlying HTTP session."""
if self._session and not self._session.closed:
await self._session.close()
# -----------------------------------------------------------------
# Internal HTTP helpers
# -----------------------------------------------------------------
def _headers(self) -> Dict[str, str]:
headers: Dict[str, str] = {"Content-Type": "application/json"}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
return headers
async def _request(
self,
method: str,
@@ -123,51 +117,43 @@ class AsyncElydoraClient:
*,
json_body: Any = None,
params: Optional[Dict[str, str]] = None,
headers: Optional[Dict[str, str]] = None,
) -> Any:
url = f"{self.base_url}{path}"
hdrs = headers or self._headers()
session = await self._get_session()
return await request_with_retries(
session,
await self._get_session(),
method,
url,
f"{self.base_url}{path}",
path=path,
max_retries=self.max_retries,
response_handler=self._handle_response,
json_body=json_body,
params=params,
headers=hdrs,
headers=build_headers(self.token),
)
async def _get_public(self, path: str) -> Any:
session = await self._get_session()
async with session.get(f"{self.base_url}{path}", timeout=TIMEOUT) as response:
return await self._handle_response(response)
@staticmethod
async def _handle_response(resp: aiohttp.ClientResponse) -> Any:
if resp.status >= 400:
try:
body = await resp.json()
except Exception:
text = await resp.text()
raise ElydoraError(
code="INTERNAL_ERROR",
message=text or "Unknown error",
status_code=resp.status,
)
err = body.get("error", {})
raise ElydoraError(
code=err.get("code", "INTERNAL_ERROR"),
message=err.get("message", ""),
request_id=err.get("request_id", ""),
details=err.get("details"),
status_code=resp.status,
)
except (aiohttp.ContentTypeError, ValueError):
body = None
raise error_from_response(resp.status, body, await resp.text())
if resp.status == 204:
return None
return await resp.json()
# -----------------------------------------------------------------
# Auth (static methods)
# -----------------------------------------------------------------
@staticmethod
async def _post_json(url: str, body: Dict[str, Any]) -> Any:
async with aiohttp.ClientSession() as session:
async with session.post(
url, json=body, headers=build_headers(None), timeout=TIMEOUT
) as response:
return await AsyncElydoraClient._handle_response(response)
@staticmethod
async def register(
@@ -177,113 +163,75 @@ class AsyncElydoraClient:
display_name: Optional[str] = None,
org_name: Optional[str] = None,
) -> AuthRegisterResponse:
"""Register a new user and organization.
.. deprecated::
Use Better Auth endpoints directly. See docs.
"""
"""Deprecated password registration; Console users sign in through Better Auth."""
warnings.warn(
"AsyncElydoraClient.register() is deprecated. Use Better Auth endpoints directly. See docs.",
"AsyncElydoraClient.register() is deprecated. Use Better Auth endpoints directly.",
DeprecationWarning,
stacklevel=2,
)
url = f"{base_url.rstrip('/')}/v1/auth/register"
body: Dict[str, Any] = {"email": email, "password": password}
if display_name is not None:
body["display_name"] = display_name
if org_name is not None:
body["org_name"] = org_name
async with aiohttp.ClientSession() as session:
async with session.post(
url, json=body, headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=30)
) as resp:
return await AsyncElydoraClient._handle_response(resp)
return await AsyncElydoraClient._post_json(
url, register_body(email, password, display_name, org_name)
)
@staticmethod
async def login(base_url: str, email: str, password: str) -> AuthLoginResponse:
"""Authenticate and receive a session token.
.. deprecated::
Use Better Auth endpoints directly. See docs.
"""
"""Deprecated password login; Console users sign in through Better Auth."""
warnings.warn(
"AsyncElydoraClient.login() is deprecated. Use Better Auth endpoints directly. See docs.",
"AsyncElydoraClient.login() is deprecated. Use Better Auth endpoints directly.",
DeprecationWarning,
stacklevel=2,
)
url = f"{base_url.rstrip('/')}/v1/auth/login"
body = {"email": email, "password": password}
async with aiohttp.ClientSession() as session:
async with session.post(
url, json=body, headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=30)
) as resp:
return await AsyncElydoraClient._handle_response(resp)
return await AsyncElydoraClient._post_json(url, {"email": email, "password": password})
async def get_me(self) -> GetMeResponse:
"""Get the current user's profile."""
return await self._request("GET", "/v1/auth/me")
async def issue_api_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
"""Issue an API token with an optional TTL."""
body: Dict[str, Any] = {}
if ttl_seconds is not None:
body["ttl_seconds"] = ttl_seconds
return await self._request("POST", "/v1/auth/token", json_body=body)
async def issue_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
"""Deprecated: Use issue_api_token() instead."""
return await self.issue_api_token(ttl_seconds=ttl_seconds)
return await self._request(
"POST", "/v1/auth/token", json_body=api_token_body(ttl_seconds)
)
async def rotate_api_token(self) -> RotateApiTokenResponse:
"""Rotate the current API token and keep the old token in 24h grace."""
return await self._request("POST", "/v1/auth/rotate", json_body={})
# -----------------------------------------------------------------
# Agent management
# -----------------------------------------------------------------
async def register_agent(self, request: RegisterAgentRequest) -> RegisterAgentResponse:
"""Register a new agent with the organization."""
require_integration_type(request.get("integration_type"))
return await self._request("POST", "/v1/agents/register", json_body=request)
async def get_agent(self, agent_id: str) -> GetAgentResponse:
"""Retrieve agent details and keys."""
return await self._request("GET", f"/v1/agents/{agent_id}")
async def freeze_agent(self, agent_id: str, reason: str) -> None:
"""Freeze an agent."""
await self._request("POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason})
async def list_agents(self) -> ListAgentsResponse:
"""List all agents for the organization."""
return await self._request("GET", "/v1/agents")
async def unfreeze_agent(self, agent_id: str, reason: str) -> UnfreezeAgentResponse:
"""Unfreeze an agent."""
return await self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason})
async def freeze_agent(self, agent_id: str, reason: str) -> FreezeAgentResponse:
return await self._request(
"POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason}
)
async def update_agent(self, agent_id: str, integration_type: IntegrationType) -> UpdateAgentResponse:
"""Update an agent's integration type."""
async def unfreeze_agent(self, agent_id: str, reason: str) -> UnfreezeAgentResponse:
return await self._request(
"POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason}
)
async def update_agent(
self, agent_id: str, integration_type: IntegrationType
) -> UpdateAgentResponse:
require_integration_type(integration_type)
return await self._request("PATCH", f"/v1/agents/{agent_id}", json_body={"integration_type": integration_type})
return await self._request(
"PATCH", f"/v1/agents/{agent_id}", json_body={"integration_type": integration_type}
)
async def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
"""Delete an agent."""
return await self._request("DELETE", f"/v1/agents/{agent_id}")
async def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
"""Revoke an agent's key."""
await self._request(
"POST",
f"/v1/agents/{agent_id}/revoke",
json_body={"kid": kid, "reason": reason},
"POST", f"/v1/agents/{agent_id}/revoke", json_body={"kid": kid, "reason": reason}
)
# -----------------------------------------------------------------
# Operations (CORE)
# -----------------------------------------------------------------
def create_operation(
self,
operation_type: str,
@@ -291,58 +239,32 @@ class AsyncElydoraClient:
action: Dict[str, Any],
payload: Union[Dict[str, Any], str, None] = None,
) -> EOR:
"""Build and sign an Elydora Operation Record (EOR).
This is synchronous because it only performs local crypto operations.
Call submit_operation() with the returned EOR to submit it.
"""
operation_id = generate_uuidv7()
issued_at = int(time.time() * 1000)
nonce = generate_nonce()
payload_hash = compute_payload_hash(payload)
chain_hash = compute_chain_hash(
self._prev_chain_hash, payload_hash, operation_id, issued_at
"""Build and sign an EOR locally; submit it with submit_operation()."""
eor, chain_hash = build_eor(
org_id=self.org_id,
agent_id=self.agent_id,
private_key=self.private_key,
kid=self._kid,
ttl_ms=self.ttl_ms,
prev_chain_hash=self._prev_chain_hash,
operation_type=operation_type,
subject=subject,
action=action,
payload=payload,
)
eor: Dict[str, Any] = {
"op_version": "1.0",
"operation_id": operation_id,
"org_id": self.org_id,
"agent_id": self.agent_id,
"issued_at": issued_at,
"ttl_ms": self.ttl_ms,
"nonce": nonce,
"operation_type": operation_type,
"subject": subject,
"action": action,
"payload": payload,
"payload_hash": payload_hash,
"prev_chain_hash": self._prev_chain_hash,
"agent_pubkey_kid": self._kid,
"signature": "",
}
eor["signature"] = sign_eor(eor, self.private_key)
self._prev_chain_hash = chain_hash
return eor # type: ignore[return-value]
return eor
async def submit_operation(self, eor: EOR) -> SubmitOperationResponse:
"""Submit a signed EOR to the server."""
return await self._request("POST", "/v1/operations", json_body=eor)
async def get_operation(self, operation_id: str) -> GetOperationResponse:
"""Retrieve an operation by ID."""
return await self._request("GET", f"/v1/operations/{operation_id}")
async def verify_operation(self, operation_id: str) -> VerifyOperationResponse:
"""Verify an operation's integrity."""
return await self._request("POST", f"/v1/operations/{operation_id}/verify", json_body={})
# -----------------------------------------------------------------
# Audit
# -----------------------------------------------------------------
return await self._request(
"POST", f"/v1/operations/{operation_id}/verify", json_body={}
)
async def query_audit(
self,
@@ -355,40 +277,23 @@ class AsyncElydoraClient:
cursor: Optional[str] = None,
limit: Optional[int] = None,
) -> AuditQueryResponse:
"""Query the tamper-evident audit log."""
body: Dict[str, Any] = {}
if org_id is not None:
body["org_id"] = org_id
if agent_id is not None:
body["agent_id"] = agent_id
if operation_type is not None:
body["operation_type"] = operation_type
if start_time is not None:
body["start_time"] = start_time
if end_time is not None:
body["end_time"] = end_time
if cursor is not None:
body["cursor"] = cursor
if limit is not None:
body["limit"] = limit
body = without_none({
"org_id": org_id,
"agent_id": agent_id,
"operation_type": operation_type,
"start_time": start_time,
"end_time": end_time,
"cursor": cursor,
"limit": limit,
})
return await self._request("POST", "/v1/audit/query", json_body=body)
# -----------------------------------------------------------------
# Epochs
# -----------------------------------------------------------------
async def list_epochs(self) -> ListEpochsResponse:
"""List all epochs for the organization."""
return await self._request("GET", "/v1/epochs")
async def get_epoch(self, epoch_id: str) -> GetEpochResponse:
"""Retrieve an epoch root record."""
return await self._request("GET", f"/v1/epochs/{epoch_id}")
# -----------------------------------------------------------------
# Exports
# -----------------------------------------------------------------
async def create_export(
self,
start_time: int,
@@ -397,97 +302,51 @@ class AsyncElydoraClient:
agent_id: Optional[str] = None,
operation_type: Optional[str] = None,
) -> CreateExportResponse:
"""Create a compliance export job."""
body: Dict[str, Any] = {
"start_time": start_time,
"end_time": end_time,
"format": format,
}
if agent_id is not None:
body["agent_id"] = agent_id
if operation_type is not None:
body["operation_type"] = operation_type
body = export_body(start_time, end_time, format, agent_id, operation_type)
return await self._request("POST", "/v1/exports", json_body=body)
async def list_exports(self) -> ListExportsResponse:
"""List all exports for the organization."""
return await self._request("GET", "/v1/exports")
async def get_export(self, export_id: str) -> GetExportResponse:
"""Retrieve export status and download URL."""
return await self._request("GET", f"/v1/exports/{export_id}")
async def download_export(self, export_id: str) -> bytes:
"""Download an export file as raw bytes."""
url = f"{self.base_url}/v1/exports/{export_id}/download"
session = await self._get_session()
async with session.get(
url, headers=self._headers(), timeout=aiohttp.ClientTimeout(total=30)
) as resp:
if resp.status >= 400:
await self._handle_response(resp)
return await resp.read()
# -----------------------------------------------------------------
# Webhooks
# -----------------------------------------------------------------
f"{self.base_url}/v1/exports/{export_id}/download",
headers=build_headers(self.token),
timeout=TIMEOUT,
) as response:
if response.status >= 400:
await self._handle_response(response)
return await response.read()
async def list_webhooks(self) -> ListWebhooksResponse:
"""List all webhooks for the organization."""
return await self._request("GET", "/v1/webhooks")
async def register_webhook(self, endpoint_url: str, events: list, secret: str) -> RegisterWebhookResponse:
"""Register a new webhook."""
return await self._request("POST", "/v1/webhooks", json_body={"endpoint_url": endpoint_url, "events": events, "secret": secret})
async def register_webhook(
self, endpoint_url: str, events: List[str], secret: str
) -> RegisterWebhookResponse:
body = {"endpoint_url": endpoint_url, "events": events, "secret": secret}
return await self._request("POST", "/v1/webhooks", json_body=body)
async def delete_webhook(self, webhook_id: str) -> None:
"""Delete a webhook."""
await self._request("DELETE", f"/v1/webhooks/{webhook_id}")
# -----------------------------------------------------------------
# Members
# -----------------------------------------------------------------
async def list_members(self) -> ListMembersResponse:
"""List all members in the organization."""
return await self._request("GET", "/v1/members")
# -----------------------------------------------------------------
# Admin events
# -----------------------------------------------------------------
async def list_admin_events(self, limit: Optional[int] = None) -> ListAdminEventsResponse:
"""List recent admin events."""
params: Dict[str, str] = {}
if limit is not None:
params["limit"] = str(limit)
return await self._request("GET", "/v1/admin/events", params=params)
# -----------------------------------------------------------------
# JWKS
# -----------------------------------------------------------------
return await self._request(
"GET", "/v1/admin/events", params=admin_events_params(limit)
)
async def get_jwks(self) -> JWKSResponse:
"""Retrieve the platform JWKS (public, no auth required)."""
url = f"{self.base_url}/.well-known/elydora/jwks.json"
session = await self._get_session()
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
return await self._handle_response(resp)
# -----------------------------------------------------------------
# Health
# -----------------------------------------------------------------
return await self._get_public("/.well-known/elydora/jwks.json")
async def health(self) -> HealthResponse:
"""Check API health (public, no auth required)."""
url = f"{self.base_url}/v1/health"
session = await self._get_session()
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
return await self._handle_response(resp)
return await self._get_public("/v1/health")
async def deep_health(self) -> DeepHealthResponse:
"""Check API deep health (public, no auth required)."""
url = f"{self.base_url}/v1/health/deep"
session = await self._get_session()
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
return await self._handle_response(resp)
return await self._get_public("/v1/health/deep")
+5 -24
View File
@@ -1,8 +1,4 @@
"""Elydora CLI — install, uninstall, and manage agent audit hooks.
Entry point: ``elydora`` console script (see pyproject.toml).
Uses only stdlib argparse — zero external dependencies for the CLI itself.
"""
"""Elydora CLI: install, uninstall, and inspect agent audit hooks."""
from __future__ import annotations
@@ -173,17 +169,15 @@ def _discover_installed_agents_or_exit() -> list[_InstalledAgent]:
def _get_plugin(agent_name: str) -> AgentPlugin:
"""Instantiate the plugin for the given agent name."""
cls = PLUGIN_MAP.get(agent_name)
if cls is None:
print(f"Error: Unknown agent '{agent_name}'.", file=sys.stderr)
print(f"Supported agents: {', '.join(get_agent_names())}", file=sys.stderr)
sys.exit(1)
_exit_with_error(
f"Unknown agent {agent_name!r}. Supported agents: {', '.join(get_agent_names())}"
)
return cls()
def cmd_install(args: argparse.Namespace) -> None:
"""Handle the 'install' subcommand."""
recover_pending_transactions()
agent_name: str = args.agent
agent_dir = _resolve_agent_directory_or_exit(args.agent_id)
@@ -197,12 +191,10 @@ def cmd_install(args: argparse.Namespace) -> None:
except (OSError, RuntimeError, ValueError) as error:
_exit_with_error(str(error))
# Derive public key to verify the private key is valid
try:
pub = get_public_key_base64url(secrets.private_key)
except Exception as exc:
print(f"Error: Invalid private key — {exc}", file=sys.stderr)
sys.exit(1)
_exit_with_error(f"Invalid private key: {exc}")
print(f"Verified key pair (public key: {pub[:16]}...)")
@@ -220,12 +212,10 @@ def cmd_install(args: argparse.Namespace) -> None:
config["token"] = secrets.token
plugin.preflight_install(config)
# Transactional adapters create and recover their own runtime directories.
if not getattr(plugin, "manages_runtime_directories", False):
ensure_private_directory(runtime_root())
ensure_private_directory(agent_dir)
# Generate and write the guard script for adapters that use the shared runtime.
if not plugin.manages_guard_runtime:
guard_script = generate_guard_script(agent_name, args.agent_id)
write_text_atomic(
@@ -240,7 +230,6 @@ def cmd_install(args: argparse.Namespace) -> None:
def cmd_uninstall(args: argparse.Namespace) -> None:
"""Handle the 'uninstall' subcommand."""
recover_pending_transactions()
explicit_agent_id = getattr(args, "agent_id", None)
elydora_dir = runtime_root()
@@ -296,7 +285,6 @@ def cmd_uninstall(args: argparse.Namespace) -> None:
def cmd_status(args: argparse.Namespace) -> None:
"""Handle the 'status' subcommand."""
recover_pending_transactions()
print("Elydora Agent Hook Status")
print("=" * 40)
@@ -309,7 +297,6 @@ def cmd_status(args: argparse.Namespace) -> None:
def cmd_agents(args: argparse.Namespace) -> None:
"""Handle the 'agents' subcommand."""
print("Supported agents:")
for name in get_agent_names():
info = SUPPORTED_AGENTS[name]
@@ -317,7 +304,6 @@ def cmd_agents(args: argparse.Namespace) -> None:
def build_parser() -> argparse.ArgumentParser:
"""Build the argparse parser."""
parser = argparse.ArgumentParser(
prog="elydora",
description="Elydora — tamper-evident audit trail for AI agents",
@@ -328,7 +314,6 @@ def build_parser() -> argparse.ArgumentParser:
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# install
install_parser = subparsers.add_parser("install", help="Install audit hook for an agent")
install_parser.add_argument("--agent", required=True, help="Agent name (e.g. claudecode, cursor)")
install_parser.add_argument("--org_id", required=True, help="Organization ID")
@@ -346,22 +331,18 @@ def build_parser() -> argparse.ArgumentParser:
)
install_parser.add_argument("--base_url", default="https://api.elydora.com", help="API base URL")
# uninstall
uninstall_parser = subparsers.add_parser("uninstall", help="Uninstall audit hook for an agent")
uninstall_parser.add_argument("--agent", required=True, help="Agent name")
uninstall_parser.add_argument("--agent_id", default=None, help="Agent ID (if omitted, scans config files for matching agent name)")
# status
subparsers.add_parser("status", help="Show installation status of all agents")
# agents
subparsers.add_parser("agents", help="List supported agents")
return parser
def main() -> None:
"""CLI entry point."""
_reject_legacy_secret_arguments(sys.argv[1:])
parser = build_parser()
args = parser.parse_args()
+95 -239
View File
@@ -2,16 +2,26 @@
from __future__ import annotations
import time
import warnings
from typing import Any, Dict, Optional, Union
from typing import Any, Dict, List, Optional, Union
import requests
from ._client_common import (
DEFAULT_BASE_URL,
GENESIS_CHAIN_HASH,
REQUEST_TIMEOUT_SECONDS,
admin_events_params,
api_token_body,
build_eor,
build_headers,
error_from_response,
export_body,
register_body,
without_none,
)
from ._retry import require_max_retries
from ._sync_http import request_with_retries
from .crypto import compute_chain_hash, compute_payload_hash, sign_eor
from .errors import ElydoraError
from .integration_types import require_integration_type
from .types import (
AuditQueryResponse,
@@ -21,35 +31,31 @@ from .types import (
DeepHealthResponse,
DeleteAgentResponse,
EOR,
FreezeAgentResponse,
GetAgentResponse,
GetEpochResponse,
GetExportResponse,
GetMeResponse,
GetOperationResponse,
HealthResponse,
IssueApiTokenResponse,
IntegrationType,
ListAdminEventsResponse,
ListMembersResponse,
ListWebhooksResponse,
RotateApiTokenResponse,
IssueApiTokenResponse,
JWKSResponse,
ListAdminEventsResponse,
ListAgentsResponse,
ListEpochsResponse,
ListExportsResponse,
ListMembersResponse,
ListWebhooksResponse,
RegisterAgentRequest,
RegisterAgentResponse,
RegisterWebhookResponse,
RotateApiTokenResponse,
SubmitOperationResponse,
UnfreezeAgentResponse,
UpdateAgentResponse,
VerifyOperationResponse,
)
from .utils import generate_nonce, generate_uuidv7
# The genesis chain hash: base64url encoding of 32 zero bytes.
# Must match the backend's GENESIS_CHAIN_HASH constant exactly.
GENESIS_CHAIN_HASH = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
class ElydoraClient:
@@ -61,7 +67,7 @@ class ElydoraClient:
private_key: Base64url-encoded 32-byte Ed25519 private key seed.
base_url: API base URL.
ttl_ms: Time-to-live for operations in milliseconds.
max_retries: Maximum number of retries on transient failures.
max_retries: Retries after the initial attempt on transient failures.
token: Optional API token for authenticated endpoints.
"""
@@ -71,7 +77,7 @@ class ElydoraClient:
agent_id: str,
private_key: str,
*,
base_url: str = "https://api.elydora.com",
base_url: str = DEFAULT_BASE_URL,
ttl_ms: int = 30000,
max_retries: int = 3,
token: Optional[str] = None,
@@ -83,29 +89,16 @@ class ElydoraClient:
self.ttl_ms = ttl_ms
self.max_retries = require_max_retries(max_retries)
self.token = token
self._prev_chain_hash = GENESIS_CHAIN_HASH
self._kid = agent_id + '-key-1'
self._kid = agent_id + "-key-1"
self._session = requests.Session()
def set_kid(self, kid: str) -> None:
"""Set the key ID used for signing operations."""
self._kid = kid
def set_prev_chain_hash(self, prev_chain_hash: str) -> None:
"""Set the previous chain hash for the next operation."""
self._prev_chain_hash = prev_chain_hash
# -----------------------------------------------------------------
# Internal HTTP helpers
# -----------------------------------------------------------------
def _headers(self) -> Dict[str, str]:
headers: Dict[str, str] = {"Content-Type": "application/json"}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
return headers
def _request(
self,
method: str,
@@ -113,49 +106,44 @@ class ElydoraClient:
*,
json_body: Any = None,
params: Optional[Dict[str, str]] = None,
headers: Optional[Dict[str, str]] = None,
) -> Any:
url = f"{self.base_url}{path}"
hdrs = headers or self._headers()
return request_with_retries(
self._session,
method,
url,
f"{self.base_url}{path}",
path=path,
max_retries=self.max_retries,
response_handler=self._handle_response,
json_body=json_body,
params=params,
headers=hdrs,
headers=build_headers(self.token),
)
def _get_public(self, path: str) -> Any:
response = self._session.get(f"{self.base_url}{path}", timeout=REQUEST_TIMEOUT_SECONDS)
return self._handle_response(response)
@staticmethod
def _handle_response(resp: requests.Response) -> Any:
if resp.status_code >= 400:
try:
body = resp.json()
except Exception:
raise ElydoraError(
code="INTERNAL_ERROR",
message=resp.text or "Unknown error",
status_code=resp.status_code,
)
err = body.get("error", {})
raise ElydoraError(
code=err.get("code", "INTERNAL_ERROR"),
message=err.get("message", resp.text),
request_id=err.get("request_id", ""),
details=err.get("details"),
status_code=resp.status_code,
)
except ValueError:
body = None
raise error_from_response(resp.status_code, body, resp.text)
if resp.status_code == 204:
return None
return resp.json()
# -----------------------------------------------------------------
# Auth (static methods)
# -----------------------------------------------------------------
@staticmethod
def _post_json(url: str, body: Dict[str, Any]) -> Any:
response = requests.post(
url,
json=body,
headers=build_headers(None),
timeout=REQUEST_TIMEOUT_SECONDS,
)
return ElydoraClient._handle_response(response)
@staticmethod
def register(
@@ -165,107 +153,65 @@ class ElydoraClient:
display_name: Optional[str] = None,
org_name: Optional[str] = None,
) -> AuthRegisterResponse:
"""Register a new user and organization.
.. deprecated::
Use Better Auth endpoints directly. See docs.
"""
"""Deprecated password registration; Console users sign in through Better Auth."""
warnings.warn(
"ElydoraClient.register() is deprecated. Use Better Auth endpoints directly. See docs.",
"ElydoraClient.register() is deprecated. Use Better Auth endpoints directly.",
DeprecationWarning,
stacklevel=2,
)
url = f"{base_url.rstrip('/')}/v1/auth/register"
body: Dict[str, Any] = {"email": email, "password": password}
if display_name is not None:
body["display_name"] = display_name
if org_name is not None:
body["org_name"] = org_name
resp = requests.post(url, json=body, headers={"Content-Type": "application/json"}, timeout=30)
return ElydoraClient._handle_response(resp)
return ElydoraClient._post_json(url, register_body(email, password, display_name, org_name))
@staticmethod
def login(base_url: str, email: str, password: str) -> AuthLoginResponse:
"""Authenticate and receive a session token.
.. deprecated::
Use Better Auth endpoints directly. See docs.
"""
"""Deprecated password login; Console users sign in through Better Auth."""
warnings.warn(
"ElydoraClient.login() is deprecated. Use Better Auth endpoints directly. See docs.",
"ElydoraClient.login() is deprecated. Use Better Auth endpoints directly.",
DeprecationWarning,
stacklevel=2,
)
url = f"{base_url.rstrip('/')}/v1/auth/login"
body = {"email": email, "password": password}
resp = requests.post(url, json=body, headers={"Content-Type": "application/json"}, timeout=30)
return ElydoraClient._handle_response(resp)
return ElydoraClient._post_json(url, {"email": email, "password": password})
def get_me(self) -> GetMeResponse:
"""Get the current user's profile."""
return self._request("GET", "/v1/auth/me")
def issue_api_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
"""Issue an API token with an optional TTL."""
body: Dict[str, Any] = {}
if ttl_seconds is not None:
body["ttl_seconds"] = ttl_seconds
return self._request("POST", "/v1/auth/token", json_body=body)
def issue_token(self, ttl_seconds: Optional[int] = None) -> IssueApiTokenResponse:
"""Deprecated: Use issue_api_token() instead."""
return self.issue_api_token(ttl_seconds=ttl_seconds)
return self._request("POST", "/v1/auth/token", json_body=api_token_body(ttl_seconds))
def rotate_api_token(self) -> RotateApiTokenResponse:
"""Rotate the current API token and keep the old token in 24h grace."""
return self._request("POST", "/v1/auth/rotate", json_body={})
# -----------------------------------------------------------------
# Agent management
# -----------------------------------------------------------------
def register_agent(self, request: RegisterAgentRequest) -> RegisterAgentResponse:
"""Register a new agent with the organization."""
require_integration_type(request.get("integration_type"))
return self._request("POST", "/v1/agents/register", json_body=request)
def get_agent(self, agent_id: str) -> GetAgentResponse:
"""Retrieve agent details and keys."""
return self._request("GET", f"/v1/agents/{agent_id}")
def freeze_agent(self, agent_id: str, reason: str) -> None:
"""Freeze an agent."""
self._request("POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason})
def list_agents(self) -> ListAgentsResponse:
"""List all agents for the organization."""
return self._request("GET", "/v1/agents")
def freeze_agent(self, agent_id: str, reason: str) -> FreezeAgentResponse:
return self._request("POST", f"/v1/agents/{agent_id}/freeze", json_body={"reason": reason})
def unfreeze_agent(self, agent_id: str, reason: str) -> UnfreezeAgentResponse:
"""Unfreeze an agent."""
return self._request("POST", f"/v1/agents/{agent_id}/unfreeze", json_body={"reason": reason})
def update_agent(self, agent_id: str, integration_type: IntegrationType) -> UpdateAgentResponse:
"""Update an agent's integration type."""
require_integration_type(integration_type)
return self._request("PATCH", f"/v1/agents/{agent_id}", json_body={"integration_type": integration_type})
return self._request(
"PATCH", f"/v1/agents/{agent_id}", json_body={"integration_type": integration_type}
)
def delete_agent(self, agent_id: str) -> DeleteAgentResponse:
"""Delete an agent."""
return self._request("DELETE", f"/v1/agents/{agent_id}")
def revoke_key(self, agent_id: str, kid: str, reason: str) -> None:
"""Revoke an agent's key."""
self._request(
"POST",
f"/v1/agents/{agent_id}/revoke",
json_body={"kid": kid, "reason": reason},
"POST", f"/v1/agents/{agent_id}/revoke", json_body={"kid": kid, "reason": reason}
)
# -----------------------------------------------------------------
# Operations (CORE)
# -----------------------------------------------------------------
def create_operation(
self,
operation_type: str,
@@ -273,61 +219,31 @@ class ElydoraClient:
action: Dict[str, Any],
payload: Union[Dict[str, Any], str, None] = None,
) -> EOR:
"""Build and sign an Elydora Operation Record (EOR).
This does NOT submit the operation to the server. Call submit_operation()
with the returned EOR to submit it.
"""
operation_id = generate_uuidv7()
issued_at = int(time.time() * 1000)
nonce = generate_nonce()
payload_hash = compute_payload_hash(payload)
chain_hash = compute_chain_hash(
self._prev_chain_hash, payload_hash, operation_id, issued_at
"""Build and sign an EOR locally; submit it with submit_operation()."""
eor, chain_hash = build_eor(
org_id=self.org_id,
agent_id=self.agent_id,
private_key=self.private_key,
kid=self._kid,
ttl_ms=self.ttl_ms,
prev_chain_hash=self._prev_chain_hash,
operation_type=operation_type,
subject=subject,
action=action,
payload=payload,
)
eor: Dict[str, Any] = {
"op_version": "1.0",
"operation_id": operation_id,
"org_id": self.org_id,
"agent_id": self.agent_id,
"issued_at": issued_at,
"ttl_ms": self.ttl_ms,
"nonce": nonce,
"operation_type": operation_type,
"subject": subject,
"action": action,
"payload": payload,
"payload_hash": payload_hash,
"prev_chain_hash": self._prev_chain_hash,
"agent_pubkey_kid": self._kid,
"signature": "",
}
# Sign the EOR (signature field excluded from canonical form)
eor["signature"] = sign_eor(eor, self.private_key)
# Update chain state
self._prev_chain_hash = chain_hash
return eor # type: ignore[return-value]
return eor
def submit_operation(self, eor: EOR) -> SubmitOperationResponse:
"""Submit a signed EOR to the server."""
return self._request("POST", "/v1/operations", json_body=eor)
def get_operation(self, operation_id: str) -> GetOperationResponse:
"""Retrieve an operation by ID."""
return self._request("GET", f"/v1/operations/{operation_id}")
def verify_operation(self, operation_id: str) -> VerifyOperationResponse:
"""Verify an operation's integrity."""
return self._request("POST", f"/v1/operations/{operation_id}/verify", json_body={})
# -----------------------------------------------------------------
# Audit
# -----------------------------------------------------------------
def query_audit(
self,
*,
@@ -339,40 +255,23 @@ class ElydoraClient:
cursor: Optional[str] = None,
limit: Optional[int] = None,
) -> AuditQueryResponse:
"""Query the tamper-evident audit log."""
body: Dict[str, Any] = {}
if org_id is not None:
body["org_id"] = org_id
if agent_id is not None:
body["agent_id"] = agent_id
if operation_type is not None:
body["operation_type"] = operation_type
if start_time is not None:
body["start_time"] = start_time
if end_time is not None:
body["end_time"] = end_time
if cursor is not None:
body["cursor"] = cursor
if limit is not None:
body["limit"] = limit
body = without_none({
"org_id": org_id,
"agent_id": agent_id,
"operation_type": operation_type,
"start_time": start_time,
"end_time": end_time,
"cursor": cursor,
"limit": limit,
})
return self._request("POST", "/v1/audit/query", json_body=body)
# -----------------------------------------------------------------
# Epochs
# -----------------------------------------------------------------
def list_epochs(self) -> ListEpochsResponse:
"""List all epochs for the organization."""
return self._request("GET", "/v1/epochs")
def get_epoch(self, epoch_id: str) -> GetEpochResponse:
"""Retrieve an epoch root record."""
return self._request("GET", f"/v1/epochs/{epoch_id}")
# -----------------------------------------------------------------
# Exports
# -----------------------------------------------------------------
def create_export(
self,
start_time: int,
@@ -381,91 +280,48 @@ class ElydoraClient:
agent_id: Optional[str] = None,
operation_type: Optional[str] = None,
) -> CreateExportResponse:
"""Create a compliance export job."""
body: Dict[str, Any] = {
"start_time": start_time,
"end_time": end_time,
"format": format,
}
if agent_id is not None:
body["agent_id"] = agent_id
if operation_type is not None:
body["operation_type"] = operation_type
body = export_body(start_time, end_time, format, agent_id, operation_type)
return self._request("POST", "/v1/exports", json_body=body)
def list_exports(self) -> ListExportsResponse:
"""List all exports for the organization."""
return self._request("GET", "/v1/exports")
def get_export(self, export_id: str) -> GetExportResponse:
"""Retrieve export status and download URL."""
return self._request("GET", f"/v1/exports/{export_id}")
def download_export(self, export_id: str) -> bytes:
"""Download an export file as raw bytes."""
url = f"{self.base_url}/v1/exports/{export_id}/download"
resp = self._session.get(url, headers=self._headers(), timeout=30)
if resp.status_code >= 400:
self._handle_response(resp)
return resp.content
# -----------------------------------------------------------------
# Webhooks
# -----------------------------------------------------------------
response = self._session.get(
f"{self.base_url}/v1/exports/{export_id}/download",
headers=build_headers(self.token),
timeout=REQUEST_TIMEOUT_SECONDS,
)
if response.status_code >= 400:
self._handle_response(response)
return response.content
def list_webhooks(self) -> ListWebhooksResponse:
"""List all webhooks for the organization."""
return self._request("GET", "/v1/webhooks")
def register_webhook(self, endpoint_url: str, events: list, secret: str) -> RegisterWebhookResponse:
"""Register a new webhook."""
return self._request("POST", "/v1/webhooks", json_body={"endpoint_url": endpoint_url, "events": events, "secret": secret})
def register_webhook(
self, endpoint_url: str, events: List[str], secret: str
) -> RegisterWebhookResponse:
body = {"endpoint_url": endpoint_url, "events": events, "secret": secret}
return self._request("POST", "/v1/webhooks", json_body=body)
def delete_webhook(self, webhook_id: str) -> None:
"""Delete a webhook."""
self._request("DELETE", f"/v1/webhooks/{webhook_id}")
# -----------------------------------------------------------------
# Members
# -----------------------------------------------------------------
def list_members(self) -> ListMembersResponse:
"""List all members in the organization."""
return self._request("GET", "/v1/members")
# -----------------------------------------------------------------
# Admin events
# -----------------------------------------------------------------
def list_admin_events(self, limit: Optional[int] = None) -> ListAdminEventsResponse:
"""List recent admin events."""
params: Dict[str, str] = {}
if limit is not None:
params["limit"] = str(limit)
return self._request("GET", "/v1/admin/events", params=params)
# -----------------------------------------------------------------
# JWKS
# -----------------------------------------------------------------
return self._request("GET", "/v1/admin/events", params=admin_events_params(limit))
def get_jwks(self) -> JWKSResponse:
"""Retrieve the platform JWKS (public, no auth required)."""
url = f"{self.base_url}/.well-known/elydora/jwks.json"
resp = self._session.get(url, timeout=30)
return self._handle_response(resp)
# -----------------------------------------------------------------
# Health
# -----------------------------------------------------------------
return self._get_public("/.well-known/elydora/jwks.json")
def health(self) -> HealthResponse:
"""Check API health (public, no auth required)."""
url = f"{self.base_url}/v1/health"
resp = self._session.get(url, timeout=30)
return self._handle_response(resp)
return self._get_public("/v1/health")
def deep_health(self) -> DeepHealthResponse:
"""Check API deep health (public, no auth required)."""
url = f"{self.base_url}/v1/health/deep"
resp = self._session.get(url, timeout=30)
return self._handle_response(resp)
return self._get_public("/v1/health/deep")
+15 -81
View File
@@ -1,25 +1,18 @@
"""Cryptographic utilities: Ed25519 signing, SHA-256, chain hash, JCS canonicalization.
Mirrors the backend implementation in ElydoraBackend/src/utils/crypto.ts exactly.
"""
"""Ed25519 signing, SHA-256, chain hash, and RFC 8785 canonicalization; mirrors the Backend."""
from __future__ import annotations
import hashlib
import json
import math
from typing import Any, Dict, Optional, Union
from typing import Any, Dict, Union
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
from .utils import base64url_decode, base64url_encode
# ---------------------------------------------------------------------------
# SHA-256
# ---------------------------------------------------------------------------
def sha256_base64url(data: Union[str, bytes]) -> str:
"""Compute SHA-256 of data and return base64url-encoded hash."""
if isinstance(data, str):
@@ -28,39 +21,21 @@ def sha256_base64url(data: Union[str, bytes]) -> str:
return base64url_encode(digest)
# ---------------------------------------------------------------------------
# JCS Canonicalization (RFC 8785)
# ---------------------------------------------------------------------------
def _jcs_serialize_number(value: Union[int, float]) -> str:
"""Serialize a number per JCS / ES2015 Number serialization rules."""
"""ES2015 number serialization: NaN and infinities become null, -0.0 becomes 0."""
if isinstance(value, bool):
# bool is a subclass of int in Python; handle before int check
return "true" if value else "false"
if isinstance(value, int):
return str(value)
# float
if math.isnan(value) or math.isinf(value):
return "null"
if value == 0.0:
# Handle -0.0 -> "0"
return "0"
# Use Python's repr which matches ES2015 for normal floats,
# but we need to ensure no trailing zeros beyond what's needed.
# json.dumps handles this correctly per the JSON spec.
return json.dumps(value)
def jcs_canonicalize(value: Any) -> str:
"""Canonicalize a value according to JCS (RFC 8785).
- Object keys sorted lexicographically
- No whitespace
- Numbers serialized using ES2015 rules
- Strings serialized with minimal JSON escaping
- undefined values in objects are omitted
"""
"""Canonicalize a value according to JCS (RFC 8785)."""
if value is None:
return "null"
@@ -78,60 +53,34 @@ def jcs_canonicalize(value: Any) -> str:
return "[" + ",".join(elements) + "]"
if isinstance(value, dict):
keys = sorted(value.keys())
pairs = []
for key in keys:
v = value[key]
if v is not None or key in value:
# Include keys with None values (maps to JSON null),
# but skip keys that don't exist (which won't happen with dict iteration)
pairs.append(json.dumps(key, ensure_ascii=False) + ":" + jcs_canonicalize(v))
pairs = [
json.dumps(key, ensure_ascii=False) + ":" + jcs_canonicalize(value[key])
for key in sorted(value.keys())
]
return "{" + ",".join(pairs) + "}"
return json.dumps(value)
# ---------------------------------------------------------------------------
# Payload hash
# ---------------------------------------------------------------------------
def compute_payload_hash(payload: Any) -> str:
"""Compute SHA-256 hash of JCS-canonicalized payload, base64url encoded."""
canonical = jcs_canonicalize(payload)
return sha256_base64url(canonical)
# ---------------------------------------------------------------------------
# Chain hash
# ---------------------------------------------------------------------------
def compute_chain_hash(
prev_chain_hash: str,
payload_hash: str,
operation_id: str,
issued_at: int,
) -> str:
"""Compute chain hash: SHA-256(prev|payload_hash|op_id|issued_at) as base64url.
Matches the backend formula exactly:
chain_hash = SHA-256(prev_chain_hash + "|" + payload_hash + "|" + operation_id + "|" + str(issued_at))
"""
"""SHA-256 of "prev|payload_hash|operation_id|issued_at" as base64url."""
input_str = f"{prev_chain_hash}|{payload_hash}|{operation_id}|{issued_at}"
return sha256_base64url(input_str)
# ---------------------------------------------------------------------------
# Ed25519 signing
# ---------------------------------------------------------------------------
def sign_ed25519(private_key_base64url: str, data: bytes) -> str:
"""Sign data with Ed25519 private key (32-byte seed, base64url encoded).
Returns a base64url-encoded 64-byte signature.
"""
"""Sign data with a base64url 32-byte Ed25519 seed; returns a base64url signature."""
seed = base64url_decode(private_key_base64url)
key = Ed25519PrivateKey.from_private_bytes(seed)
signature = key.sign(data)
@@ -139,29 +88,14 @@ def sign_ed25519(private_key_base64url: str, data: bytes) -> str:
def get_public_key_base64url(private_key_base64url: str) -> str:
"""Derive the Ed25519 public key from the private key seed.
Returns base64url-encoded 32-byte public key.
"""
"""Derive the base64url Ed25519 public key from a base64url seed."""
seed = base64url_decode(private_key_base64url)
key = Ed25519PrivateKey.from_private_bytes(seed)
pub = key.public_key()
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
raw_bytes = pub.public_bytes(Encoding.Raw, PublicFormat.Raw)
return base64url_encode(raw_bytes)
# ---------------------------------------------------------------------------
# EOR signing
# ---------------------------------------------------------------------------
public_key = Ed25519PrivateKey.from_private_bytes(seed).public_key()
return base64url_encode(public_key.public_bytes(Encoding.Raw, PublicFormat.Raw))
def sign_eor(eor_dict: Dict[str, Any], private_key_base64url: str) -> str:
"""Sign an EOR by canonicalizing all fields except 'signature', then signing.
Returns the base64url-encoded Ed25519 signature.
"""
# Build the signable object: all EOR fields except 'signature'
"""Sign the JCS form of every EOR field except signature."""
signable = {k: v for k, v in eor_dict.items() if k != "signature"}
canonical = jcs_canonicalize(signable)
return sign_ed25519(private_key_base64url, canonical.encode("utf-8"))
+55 -77
View File
@@ -1,7 +1,4 @@
"""Type definitions matching the Elydora backend API contract.
All types use TypedDict for structural typing, compatible with Python 3.10+.
"""
"""TypedDict definitions matching the Elydora Backend API contract."""
import sys
from typing import Any, Dict, List, Optional, Tuple
@@ -12,10 +9,6 @@ else:
from typing_extensions import Literal, NotRequired, TypedDict
# ---------------------------------------------------------------------------
# Enums (as Literal types)
# ---------------------------------------------------------------------------
AgentStatus = Literal["active", "frozen", "revoked"]
KeyStatus = Literal["active", "retired", "revoked"]
ExportStatus = Literal["queued", "running", "done", "failed"]
@@ -53,22 +46,32 @@ INTEGRATION_TYPES: Tuple[IntegrationType, ...] = (
"enterprise", "gui", "sdk", "other",
)
AdminAction = Literal[
"agent.register",
"agent.update",
"agent.freeze",
"agent.unfreeze",
"agent.revoke_key",
"agent.revoke",
"agent.delete",
"key.revoke",
"export.create",
"org.create",
"org.update",
"member.invite",
"invitation.cancel",
"member.remove",
"member.role_change",
"agent.assign",
"agent.unassign",
"webhook.register",
"webhook.delete",
"org.update",
]
ErrorCode = Literal[
"INVALID_SIGNATURE",
"UNKNOWN_AGENT",
"KEY_REVOKED",
"KEY_RETIRED",
"AGENT_FROZEN",
"AGENT_REVOKED",
"TTL_EXPIRED",
"REPLAY_DETECTED",
"PREV_HASH_MISMATCH",
@@ -81,11 +84,6 @@ ErrorCode = Literal[
"VALIDATION_ERROR",
]
# ---------------------------------------------------------------------------
# Entity types
# ---------------------------------------------------------------------------
class Agent(TypedDict):
agent_id: str
org_id: str
@@ -175,11 +173,6 @@ class Export(TypedDict):
completed_at: Optional[int]
# ---------------------------------------------------------------------------
# Protocol types
# ---------------------------------------------------------------------------
class EOR(TypedDict):
"""Elydora Operation Record."""
op_version: Literal["1.0"]
@@ -215,11 +208,6 @@ class EAR(TypedDict):
elydora_signature: str
# ---------------------------------------------------------------------------
# API request types
# ---------------------------------------------------------------------------
class RegisterAgentKeyParam(TypedDict):
kid: str
public_key: str
@@ -234,21 +222,6 @@ class RegisterAgentRequest(TypedDict):
responsible_entity: NotRequired[str]
class AuditQueryParams(TypedDict, total=False):
org_id: str
agent_id: str
operation_type: str
start_time: int
end_time: int
cursor: str
limit: int
# ---------------------------------------------------------------------------
# API response types
# ---------------------------------------------------------------------------
class RegisterAgentResponse(TypedDict):
agent: Agent
keys: List[AgentKey]
@@ -266,6 +239,7 @@ class SubmitOperationResponse(TypedDict):
class GetOperationResponse(TypedDict, total=False):
operation: Operation
receipt: Receipt
payload: Any
class VerifyChecks(TypedDict, total=False):
@@ -348,8 +322,27 @@ class DeleteAgentResponse(TypedDict):
deleted: bool
class AuthMeUser(TypedDict):
user_id: str
org_id: Optional[str]
email: str
display_name: str
role: RbacRole
status: Literal["active", "suspended"]
created_at: int
updated_at: int
onboarding_completed: bool
class CurrentOrganization(TypedDict):
org_id: Optional[str]
ba_org_id: Optional[str]
role: RbacRole
class GetMeResponse(TypedDict):
user: User
user: AuthMeUser
current_organization: CurrentOrganization
class IssueApiTokenResponse(TypedDict):
@@ -364,39 +357,30 @@ class RotateApiTokenResponse(TypedDict):
token_id: str
# Backward compatibility aliases (deprecated)
IssueTokenResponse = IssueApiTokenResponse
RotateTokenResponse = RotateApiTokenResponse
class HealthResponse(TypedDict):
status: str
version: str
protocol_version: str
capabilities: Dict[str, str]
timestamp: int
# ---------------------------------------------------------------------------
# Additional entity types
# ---------------------------------------------------------------------------
class AdminEvent(TypedDict):
event_id: str
org_id: str
actor_user_id: str
actor: str
action: str
target_type: str
target_id: str
metadata: Optional[Dict[str, Any]]
details: Optional[str]
created_at: int
class AgentAssignment(TypedDict):
assignment_id: str
id: str
agent_id: str
user_id: str
org_id: str
assigned_to: str
assigned_by: str
created_at: int
@@ -406,48 +390,36 @@ class WebhookEntry(TypedDict):
org_id: str
endpoint_url: str
events: List[str]
status: Literal["active", "disabled"]
created_at: int
updated_at: int
class MemberEntry(TypedDict):
member_id: str
user_id: str
org_id: str
role: str
joined_at: str
email: str
display_name: str
role: RbacRole
status: Literal["active", "suspended"]
created_at: int
# ---------------------------------------------------------------------------
# Additional request types
# ---------------------------------------------------------------------------
class UpdateAgentRequest(TypedDict):
integration_type: IntegrationType
# ---------------------------------------------------------------------------
# Additional response types
# ---------------------------------------------------------------------------
class UpdateAgentResponse(TypedDict):
agent: Agent
class FreezeAgentResponse(TypedDict):
agent_id: str
agent: Agent
previous_status: AgentStatus
status: AgentStatus
class UnfreezeAgentResponse(TypedDict):
agent_id: str
agent: Agent
previous_status: AgentStatus
status: AgentStatus
class ListWebhooksResponse(TypedDict):
@@ -464,12 +436,18 @@ class ListMembersResponse(TypedDict):
class ListAdminEventsResponse(TypedDict):
events: List[AdminEvent]
cursor: Optional[str]
class DeepHealthResponse(TypedDict, total=False):
status: str
class DependencyHealth(TypedDict):
status: Literal["ok", "failed"]
latency_ms: int
contract_phase: NotRequired[Literal["expand", "contract"]]
class DeepHealthResponse(TypedDict):
status: Literal["healthy", "degraded"]
version: str
protocol_version: str
capabilities: Dict[str, str]
timestamp: int
checks: Dict[str, Any]
dependencies: Dict[str, DependencyHealth]
+4 -19
View File
@@ -28,27 +28,12 @@ def generate_nonce() -> str:
def generate_uuidv7() -> str:
"""Generate a UUIDv7 (time-ordered, random) as a string.
Layout (RFC 9562):
- 48-bit Unix timestamp in milliseconds
- 4-bit version (0b0111)
- 12-bit random
- 2-bit variant (0b10)
- 62-bit random
"""
"""UUIDv7 (RFC 9562): 48-bit ms timestamp, version 7, variant 10, 74 random bits."""
timestamp_ms = int(time.time() * 1000)
# 48-bit timestamp
ts_bytes = struct.pack(">Q", timestamp_ms)[2:] # last 6 bytes of 8-byte big-endian
# 2 bytes: version (4 bits = 0x7) + 12 random bits
rand_a = struct.unpack(">H", os.urandom(2))[0]
rand_a = (rand_a & 0x0FFF) | 0x7000 # set version nibble
# 8 bytes: variant (2 bits = 0b10) + 62 random bits
ts_bytes = struct.pack(">Q", timestamp_ms)[2:]
rand_a = (struct.unpack(">H", os.urandom(2))[0] & 0x0FFF) | 0x7000
rand_b = bytearray(os.urandom(8))
rand_b[0] = (rand_b[0] & 0x3F) | 0x80 # set variant bits
rand_b[0] = (rand_b[0] & 0x3F) | 0x80
uuid_bytes = ts_bytes + struct.pack(">H", rand_a) + bytes(rand_b)
+2 -2
View File
@@ -215,7 +215,7 @@ def test_sync_client_does_not_replay_non_idempotent_requests(
)
with pytest.raises(ElydoraError) as error:
client.issue_token()
client.issue_api_token()
assert error.value.status_code == 503
assert scripted_server.methods == ["POST"]
assert delays == []
@@ -241,7 +241,7 @@ async def test_async_client_does_not_replay_non_idempotent_requests(
)
try:
with pytest.raises(ElydoraError) as error:
await client.issue_token()
await client.issue_api_token()
assert error.value.status_code == 503
assert scripted_server.methods == ["POST"]
assert delays == []