creduent 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- creduent-0.1.0/PKG-INFO +96 -0
- creduent-0.1.0/README.md +81 -0
- creduent-0.1.0/creduent/__init__.py +29 -0
- creduent-0.1.0/creduent/attest.py +80 -0
- creduent-0.1.0/creduent/crypto.py +9 -0
- creduent-0.1.0/creduent/exceptions.py +19 -0
- creduent-0.1.0/creduent/register.py +78 -0
- creduent-0.1.0/creduent/sign.py +146 -0
- creduent-0.1.0/creduent/utils.py +75 -0
- creduent-0.1.0/creduent/verify.py +238 -0
- creduent-0.1.0/creduent.egg-info/PKG-INFO +96 -0
- creduent-0.1.0/creduent.egg-info/SOURCES.txt +18 -0
- creduent-0.1.0/creduent.egg-info/dependency_links.txt +1 -0
- creduent-0.1.0/creduent.egg-info/entry_points.txt +3 -0
- creduent-0.1.0/creduent.egg-info/requires.txt +4 -0
- creduent-0.1.0/creduent.egg-info/top_level.txt +1 -0
- creduent-0.1.0/pyproject.toml +30 -0
- creduent-0.1.0/setup.cfg +4 -0
- creduent-0.1.0/setup.py +19 -0
- creduent-0.1.0/tests/test_sdk.py +116 -0
creduent-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: creduent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Creduent Protocol SDK — cryptographic identity for AI agents
|
|
5
|
+
License: MIT
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: cryptography
|
|
12
|
+
Requires-Dist: requests
|
|
13
|
+
Requires-Dist: jcs
|
|
14
|
+
Requires-Dist: fastapi
|
|
15
|
+
|
|
16
|
+
# Creduent Python SDK
|
|
17
|
+
|
|
18
|
+
The official Python SDK for the **Creduent Protocol** — a federated, open trust-verification layer and cryptographic identity infrastructure for autonomous AI agents.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Install the self-contained package from PyPI with a single command:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install creduent
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quickstart
|
|
29
|
+
|
|
30
|
+
Below is a complete quickstart demonstrating keypair generation, self-signing, verification, registration, and attestation resolution using the SDK.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from creduent import (
|
|
34
|
+
generate_keys,
|
|
35
|
+
sign,
|
|
36
|
+
verify,
|
|
37
|
+
register,
|
|
38
|
+
attest,
|
|
39
|
+
CreduEntError
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
# 1. Generate a new Ed25519 keypair
|
|
44
|
+
private_key_pem, public_key_str = generate_keys()
|
|
45
|
+
print(f"Generated Public Key: {public_key_str}\n")
|
|
46
|
+
|
|
47
|
+
# 2. Sign a draft agent.json document
|
|
48
|
+
draft_document = {
|
|
49
|
+
"agent_id": "agent://creduent/reconbot",
|
|
50
|
+
"owner": "Creduent",
|
|
51
|
+
"public_key": public_key_str,
|
|
52
|
+
"endpoint": "https://api.idevsec.com/recon",
|
|
53
|
+
"capabilities": ["osint", "dns_lookup", "vulnerability_scan"]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
signed_doc = sign(draft_document, private_key_pem)
|
|
57
|
+
print("Signed agent.json:")
|
|
58
|
+
print(signed_doc)
|
|
59
|
+
print()
|
|
60
|
+
|
|
61
|
+
# 3. Verify a self-signed agent.json (from dict, URL, domain, or agent:// URI)
|
|
62
|
+
# Verification with a document dictionary
|
|
63
|
+
result = verify(signed_doc)
|
|
64
|
+
print(f"Self-Signed Verification Result (dict): {result.valid}")
|
|
65
|
+
|
|
66
|
+
# Verification with a live agent URL
|
|
67
|
+
live_result = verify("https://api.idevsec.com/.well-known/agent.json")
|
|
68
|
+
print(f"Live Verification Result (URL): {live_result.valid}")
|
|
69
|
+
print(f"Live Agent ID: {live_result.agent_id}")
|
|
70
|
+
print(f"Live Capabilities: {live_result.capabilities}\n")
|
|
71
|
+
|
|
72
|
+
# 4. Register an agent with the Creduent registry
|
|
73
|
+
# Returns RegisterResult with success (bool), attestation (dict|None), error (str|None)
|
|
74
|
+
reg_result = register(
|
|
75
|
+
agent_id="agent://creduent/reconbot",
|
|
76
|
+
domain="api.idevsec.com",
|
|
77
|
+
agent_json_url="https://api.idevsec.com/.well-known/agent.json"
|
|
78
|
+
)
|
|
79
|
+
print(f"Registration Successful: {reg_result.success}")
|
|
80
|
+
print(f"Attestation: {reg_result.attestation}\n")
|
|
81
|
+
|
|
82
|
+
# 5. Fetch and validate an active attestation for an agent
|
|
83
|
+
# Returns AttestResult with attested (bool), level (str), issued_at, expires_at, error (str|None)
|
|
84
|
+
attest_result = attest("agent://creduent/reconbot")
|
|
85
|
+
print(f"Is Attested: {attest_result.attested}")
|
|
86
|
+
print(f"Attestation Level: {attest_result.level}")
|
|
87
|
+
print(f"Issued At: {attest_result.issued_at}")
|
|
88
|
+
print(f"Expires At: {attest_result.expires_at}\n")
|
|
89
|
+
|
|
90
|
+
except CreduEntError as e:
|
|
91
|
+
print(f"Creduent Protocol Error occurred: {e}")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Protocol Specification
|
|
95
|
+
|
|
96
|
+
For full information on the cryptographic standards, JCS canonicalization, and the federated verification workflows, read the complete [Creduent Protocol Specification](https://github.com/cyberfascinate/creduent).
|
creduent-0.1.0/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Creduent Python SDK
|
|
2
|
+
|
|
3
|
+
The official Python SDK for the **Creduent Protocol** — a federated, open trust-verification layer and cryptographic identity infrastructure for autonomous AI agents.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Install the self-contained package from PyPI with a single command:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install creduent
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quickstart
|
|
14
|
+
|
|
15
|
+
Below is a complete quickstart demonstrating keypair generation, self-signing, verification, registration, and attestation resolution using the SDK.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from creduent import (
|
|
19
|
+
generate_keys,
|
|
20
|
+
sign,
|
|
21
|
+
verify,
|
|
22
|
+
register,
|
|
23
|
+
attest,
|
|
24
|
+
CreduEntError
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
# 1. Generate a new Ed25519 keypair
|
|
29
|
+
private_key_pem, public_key_str = generate_keys()
|
|
30
|
+
print(f"Generated Public Key: {public_key_str}\n")
|
|
31
|
+
|
|
32
|
+
# 2. Sign a draft agent.json document
|
|
33
|
+
draft_document = {
|
|
34
|
+
"agent_id": "agent://creduent/reconbot",
|
|
35
|
+
"owner": "Creduent",
|
|
36
|
+
"public_key": public_key_str,
|
|
37
|
+
"endpoint": "https://api.idevsec.com/recon",
|
|
38
|
+
"capabilities": ["osint", "dns_lookup", "vulnerability_scan"]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
signed_doc = sign(draft_document, private_key_pem)
|
|
42
|
+
print("Signed agent.json:")
|
|
43
|
+
print(signed_doc)
|
|
44
|
+
print()
|
|
45
|
+
|
|
46
|
+
# 3. Verify a self-signed agent.json (from dict, URL, domain, or agent:// URI)
|
|
47
|
+
# Verification with a document dictionary
|
|
48
|
+
result = verify(signed_doc)
|
|
49
|
+
print(f"Self-Signed Verification Result (dict): {result.valid}")
|
|
50
|
+
|
|
51
|
+
# Verification with a live agent URL
|
|
52
|
+
live_result = verify("https://api.idevsec.com/.well-known/agent.json")
|
|
53
|
+
print(f"Live Verification Result (URL): {live_result.valid}")
|
|
54
|
+
print(f"Live Agent ID: {live_result.agent_id}")
|
|
55
|
+
print(f"Live Capabilities: {live_result.capabilities}\n")
|
|
56
|
+
|
|
57
|
+
# 4. Register an agent with the Creduent registry
|
|
58
|
+
# Returns RegisterResult with success (bool), attestation (dict|None), error (str|None)
|
|
59
|
+
reg_result = register(
|
|
60
|
+
agent_id="agent://creduent/reconbot",
|
|
61
|
+
domain="api.idevsec.com",
|
|
62
|
+
agent_json_url="https://api.idevsec.com/.well-known/agent.json"
|
|
63
|
+
)
|
|
64
|
+
print(f"Registration Successful: {reg_result.success}")
|
|
65
|
+
print(f"Attestation: {reg_result.attestation}\n")
|
|
66
|
+
|
|
67
|
+
# 5. Fetch and validate an active attestation for an agent
|
|
68
|
+
# Returns AttestResult with attested (bool), level (str), issued_at, expires_at, error (str|None)
|
|
69
|
+
attest_result = attest("agent://creduent/reconbot")
|
|
70
|
+
print(f"Is Attested: {attest_result.attested}")
|
|
71
|
+
print(f"Attestation Level: {attest_result.level}")
|
|
72
|
+
print(f"Issued At: {attest_result.issued_at}")
|
|
73
|
+
print(f"Expires At: {attest_result.expires_at}\n")
|
|
74
|
+
|
|
75
|
+
except CreduEntError as e:
|
|
76
|
+
print(f"Creduent Protocol Error occurred: {e}")
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Protocol Specification
|
|
80
|
+
|
|
81
|
+
For full information on the cryptographic standards, JCS canonicalization, and the federated verification workflows, read the complete [Creduent Protocol Specification](https://github.com/cyberfascinate/creduent).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Creduent Protocol SDK — Cryptographic identity verification for AI agents.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from creduent.sign import generate_keys, sign
|
|
6
|
+
from creduent.verify import verify, VerifyResult
|
|
7
|
+
from creduent.register import register, RegisterResult
|
|
8
|
+
from creduent.attest import attest, AttestResult
|
|
9
|
+
from creduent.exceptions import (
|
|
10
|
+
CreduEntError,
|
|
11
|
+
VerificationError,
|
|
12
|
+
RegistrationError,
|
|
13
|
+
AttestationError
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"generate_keys",
|
|
18
|
+
"sign",
|
|
19
|
+
"verify",
|
|
20
|
+
"VerifyResult",
|
|
21
|
+
"register",
|
|
22
|
+
"RegisterResult",
|
|
23
|
+
"attest",
|
|
24
|
+
"AttestResult",
|
|
25
|
+
"CreduEntError",
|
|
26
|
+
"VerificationError",
|
|
27
|
+
"RegistrationError",
|
|
28
|
+
"AttestationError"
|
|
29
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from creduent.exceptions import AttestationError
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class AttestResult:
|
|
9
|
+
attested: bool
|
|
10
|
+
level: Optional[str]
|
|
11
|
+
issued_at: Optional[str]
|
|
12
|
+
expires_at: Optional[str]
|
|
13
|
+
error: Optional[str] = None
|
|
14
|
+
|
|
15
|
+
def attest(
|
|
16
|
+
agent_id: str,
|
|
17
|
+
registry_url: str = "https://api.idevsec.com"
|
|
18
|
+
) -> AttestResult:
|
|
19
|
+
"""
|
|
20
|
+
Fetch attestation for an agent from the Creduent registry.
|
|
21
|
+
Returns AttestResult.
|
|
22
|
+
Raises AttestationError on network, timeout, or registry 5xx failures.
|
|
23
|
+
"""
|
|
24
|
+
base_url = registry_url.rstrip('/')
|
|
25
|
+
|
|
26
|
+
# URL escape agent_id if needed, but since it's a path parameter:
|
|
27
|
+
# FastAPI path parameter matches `/attest/{agent_id:path}`
|
|
28
|
+
# E.g. /registry/attest/agent://creduent/reconbot
|
|
29
|
+
# Note: double slashes in agent:// might get collapsed by some proxies or routers.
|
|
30
|
+
# The registry endpoint router handles path normalization.
|
|
31
|
+
if base_url.endswith('/registry'):
|
|
32
|
+
endpoint = f"{base_url}/attest/{agent_id}"
|
|
33
|
+
else:
|
|
34
|
+
endpoint = f"{base_url}/registry/attest/{agent_id}"
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
response = requests.get(endpoint, timeout=10)
|
|
38
|
+
except requests.RequestException as e:
|
|
39
|
+
# Fallback without /registry if default failed
|
|
40
|
+
if not base_url.endswith('/registry'):
|
|
41
|
+
fallback_endpoint = f"{base_url}/attest/{agent_id}"
|
|
42
|
+
try:
|
|
43
|
+
response = requests.get(fallback_endpoint, timeout=10)
|
|
44
|
+
except requests.RequestException as inner_e:
|
|
45
|
+
raise AttestationError(f"Connection to Creduent registry failed: {inner_e}")
|
|
46
|
+
else:
|
|
47
|
+
raise AttestationError(f"Connection to Creduent registry failed: {e}")
|
|
48
|
+
|
|
49
|
+
if response.status_code == 200:
|
|
50
|
+
try:
|
|
51
|
+
data = response.json()
|
|
52
|
+
return AttestResult(
|
|
53
|
+
attested=True,
|
|
54
|
+
level=data.get("level", "verified"),
|
|
55
|
+
issued_at=data.get("issued_at"),
|
|
56
|
+
expires_at=data.get("expires_at"),
|
|
57
|
+
error=None
|
|
58
|
+
)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
raise AttestationError(f"Registry returned invalid JSON: {e}")
|
|
61
|
+
elif response.status_code == 404:
|
|
62
|
+
# Agent is not registered/attested
|
|
63
|
+
return AttestResult(
|
|
64
|
+
attested=False,
|
|
65
|
+
level=None,
|
|
66
|
+
issued_at=None,
|
|
67
|
+
expires_at=None,
|
|
68
|
+
error="Attestation not found or expired"
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
# Server or validation error
|
|
72
|
+
err_detail = response.text
|
|
73
|
+
try:
|
|
74
|
+
err_json = response.json()
|
|
75
|
+
if isinstance(err_json, dict) and "detail" in err_json:
|
|
76
|
+
err_detail = err_json["detail"]
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
raise AttestationError(f"Attestation query failed with HTTP {response.status_code}: {err_detail}")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Creduent Python SDK custom exceptions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
class CreduEntError(Exception):
|
|
6
|
+
"""Base exception for all Creduent errors."""
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
class VerificationError(CreduEntError):
|
|
10
|
+
"""Exception raised when identity or cryptographic verification fails."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
class RegistrationError(CreduEntError):
|
|
14
|
+
"""Exception raised when agent registration with the registry fails."""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
class AttestationError(CreduEntError):
|
|
18
|
+
"""Exception raised when querying or validating registry attestations fails."""
|
|
19
|
+
pass
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import requests
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from creduent.exceptions import RegistrationError
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class RegisterResult:
|
|
10
|
+
success: bool
|
|
11
|
+
attestation: Optional[dict]
|
|
12
|
+
error: Optional[str] = None
|
|
13
|
+
|
|
14
|
+
def register(
|
|
15
|
+
agent_id: str,
|
|
16
|
+
domain: str,
|
|
17
|
+
agent_json_url: str,
|
|
18
|
+
registry_url: str = "https://api.idevsec.com"
|
|
19
|
+
) -> RegisterResult:
|
|
20
|
+
"""
|
|
21
|
+
Register agent with Creduent registry.
|
|
22
|
+
Returns RegisterResult.
|
|
23
|
+
Raises RegistrationError on network, timeout, or validation failure.
|
|
24
|
+
"""
|
|
25
|
+
base_url = registry_url.rstrip('/')
|
|
26
|
+
# If URL already ends with /registry, the endpoint is /register.
|
|
27
|
+
# Otherwise, it's /registry/register.
|
|
28
|
+
if base_url.endswith('/registry'):
|
|
29
|
+
endpoint = f"{base_url}/register"
|
|
30
|
+
else:
|
|
31
|
+
endpoint = f"{base_url}/registry/register"
|
|
32
|
+
|
|
33
|
+
payload = {
|
|
34
|
+
"agent_id": agent_id,
|
|
35
|
+
"domain": domain,
|
|
36
|
+
"agent_json_url": agent_json_url
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
response = requests.post(
|
|
41
|
+
endpoint,
|
|
42
|
+
json=payload,
|
|
43
|
+
headers={"Content-Type": "application/json"},
|
|
44
|
+
timeout=10
|
|
45
|
+
)
|
|
46
|
+
except requests.RequestException as e:
|
|
47
|
+
# If the preferred endpoint failed, try fallback /register directly
|
|
48
|
+
if not base_url.endswith('/registry'):
|
|
49
|
+
fallback_endpoint = f"{base_url}/register"
|
|
50
|
+
try:
|
|
51
|
+
response = requests.post(
|
|
52
|
+
fallback_endpoint,
|
|
53
|
+
json=payload,
|
|
54
|
+
headers={"Content-Type": "application/json"},
|
|
55
|
+
timeout=10
|
|
56
|
+
)
|
|
57
|
+
except requests.RequestException as inner_e:
|
|
58
|
+
raise RegistrationError(f"Connection to Creduent registry failed: {inner_e}")
|
|
59
|
+
else:
|
|
60
|
+
raise RegistrationError(f"Connection to Creduent registry failed: {e}")
|
|
61
|
+
|
|
62
|
+
if response.status_code == 200:
|
|
63
|
+
try:
|
|
64
|
+
attestation = response.json()
|
|
65
|
+
return RegisterResult(success=True, attestation=attestation, error=None)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
raise RegistrationError(f"Registry returned invalid JSON: {e}")
|
|
68
|
+
else:
|
|
69
|
+
# Non-200 error
|
|
70
|
+
err_detail = response.text
|
|
71
|
+
try:
|
|
72
|
+
err_json = response.json()
|
|
73
|
+
if isinstance(err_json, dict) and "detail" in err_json:
|
|
74
|
+
err_detail = err_json["detail"]
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
raise RegistrationError(f"Registration failed with HTTP {response.status_code}: {err_detail}")
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
import base64
|
|
5
|
+
import argparse
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
8
|
+
from cryptography.hazmat.primitives import serialization
|
|
9
|
+
from creduent.crypto import canonicalize
|
|
10
|
+
from creduent.exceptions import CreduEntError
|
|
11
|
+
|
|
12
|
+
def generate_keys() -> tuple[str, str]:
|
|
13
|
+
"""
|
|
14
|
+
Generate a new Ed25519 keypair.
|
|
15
|
+
Returns: (private_key_pem_string, "ed25519:<base64>" public_key_string)
|
|
16
|
+
"""
|
|
17
|
+
try:
|
|
18
|
+
private_key = ed25519.Ed25519PrivateKey.generate()
|
|
19
|
+
|
|
20
|
+
# Format Private Key as PEM string
|
|
21
|
+
private_pem_bytes = private_key.private_bytes(
|
|
22
|
+
encoding=serialization.Encoding.PEM,
|
|
23
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
24
|
+
encryption_algorithm=serialization.NoEncryption()
|
|
25
|
+
)
|
|
26
|
+
private_key_pem = private_pem_bytes.decode('utf-8')
|
|
27
|
+
|
|
28
|
+
# Extract and format Public Key
|
|
29
|
+
public_key = private_key.public_key()
|
|
30
|
+
public_bytes = public_key.public_bytes(
|
|
31
|
+
encoding=serialization.Encoding.Raw,
|
|
32
|
+
format=serialization.PublicFormat.Raw
|
|
33
|
+
)
|
|
34
|
+
public_b64 = base64.b64encode(public_bytes).decode('utf-8')
|
|
35
|
+
public_key_str = f"ed25519:{public_b64}"
|
|
36
|
+
|
|
37
|
+
return private_key_pem, public_key_str
|
|
38
|
+
except Exception as e:
|
|
39
|
+
raise CreduEntError(f"Failed to generate keys: {e}")
|
|
40
|
+
|
|
41
|
+
def sign(draft: dict, private_key_pem: str) -> dict:
|
|
42
|
+
"""
|
|
43
|
+
Sign a draft agent.json dict and return the signed document.
|
|
44
|
+
Adds JCS canonicalization (RFC 8785) + Ed25519 signature field.
|
|
45
|
+
"""
|
|
46
|
+
if not isinstance(draft, dict):
|
|
47
|
+
raise CreduEntError("Draft must be a dictionary")
|
|
48
|
+
|
|
49
|
+
doc = draft.copy()
|
|
50
|
+
|
|
51
|
+
# Normalize fields
|
|
52
|
+
doc["version"] = "1.0"
|
|
53
|
+
if "issued_at" not in doc:
|
|
54
|
+
doc["issued_at"] = datetime.now(timezone.utc).isoformat(timespec='seconds').replace("+00:00", "Z")
|
|
55
|
+
|
|
56
|
+
# Remove signature before signing
|
|
57
|
+
doc.pop("signature", None)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
# Load the Ed25519 private key
|
|
61
|
+
private_key = serialization.load_pem_private_key(
|
|
62
|
+
private_key_pem.encode('utf-8'),
|
|
63
|
+
password=None
|
|
64
|
+
)
|
|
65
|
+
if not isinstance(private_key, ed25519.Ed25519PrivateKey):
|
|
66
|
+
raise ValueError("Key is not an Ed25519 private key")
|
|
67
|
+
except Exception as e:
|
|
68
|
+
raise CreduEntError(f"Failed parsing private key PEM: {e}")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
# JCS canonicalization
|
|
72
|
+
canonical_str = canonicalize(doc)
|
|
73
|
+
canonical_bytes = canonical_str.encode('utf-8')
|
|
74
|
+
|
|
75
|
+
# Sign payload
|
|
76
|
+
signature_bytes = private_key.sign(canonical_bytes)
|
|
77
|
+
signature_b64 = base64.b64encode(signature_bytes).decode('utf-8')
|
|
78
|
+
|
|
79
|
+
# Append signature
|
|
80
|
+
doc["signature"] = signature_b64
|
|
81
|
+
return doc
|
|
82
|
+
except Exception as e:
|
|
83
|
+
raise CreduEntError(f"Failed during JCS canonicalization or signing: {e}")
|
|
84
|
+
|
|
85
|
+
def main():
|
|
86
|
+
parser = argparse.ArgumentParser(description="Creduent Protocol - agent.json Signing CLI")
|
|
87
|
+
subparsers = parser.add_subparsers(dest="command", help="Subcommand to execute")
|
|
88
|
+
|
|
89
|
+
# generate-keys parser
|
|
90
|
+
subparsers.add_parser("generate-keys", help="Generate Ed25519 private key and public key string")
|
|
91
|
+
|
|
92
|
+
# sign parser
|
|
93
|
+
sign_parser = subparsers.add_parser("sign", help="Sign a draft agent.json payload")
|
|
94
|
+
sign_parser.add_argument("--key", required=True, help="Path to Ed25519 private_key.pem")
|
|
95
|
+
sign_parser.add_argument("--input", required=True, help="Path to unsigned draft JSON file")
|
|
96
|
+
sign_parser.add_argument("--output", required=True, help="Path to write the signed JSON output file")
|
|
97
|
+
|
|
98
|
+
args = parser.parse_args()
|
|
99
|
+
|
|
100
|
+
if args.command == "generate-keys":
|
|
101
|
+
try:
|
|
102
|
+
private_pem, public_key_str = generate_keys()
|
|
103
|
+
|
|
104
|
+
# Save private key PEM locally
|
|
105
|
+
with open("private_key.pem", "w", encoding="utf-8") as f:
|
|
106
|
+
f.write(private_pem)
|
|
107
|
+
print("[SUCCESS] Private key saved to private_key.pem (KEEP THIS SECRET!)")
|
|
108
|
+
|
|
109
|
+
print("\n" + "="*50)
|
|
110
|
+
print("YOUR PUBLIC KEY (Add this to your agent.json):")
|
|
111
|
+
print(public_key_str)
|
|
112
|
+
print("="*50)
|
|
113
|
+
except Exception as e:
|
|
114
|
+
print(f"[-] Error: {e}", file=sys.stderr)
|
|
115
|
+
sys.exit(1)
|
|
116
|
+
|
|
117
|
+
elif args.command == "sign":
|
|
118
|
+
if not os.path.exists(args.key):
|
|
119
|
+
print(f"[-] Error: Key file not found at {args.key}", file=sys.stderr)
|
|
120
|
+
sys.exit(1)
|
|
121
|
+
|
|
122
|
+
if not os.path.exists(args.input):
|
|
123
|
+
print(f"[-] Error: Input document not found at {args.input}", file=sys.stderr)
|
|
124
|
+
sys.exit(1)
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
with open(args.key, "r", encoding="utf-8") as f:
|
|
128
|
+
private_pem = f.read()
|
|
129
|
+
|
|
130
|
+
with open(args.input, "r", encoding="utf-8") as f:
|
|
131
|
+
draft = json.load(f)
|
|
132
|
+
|
|
133
|
+
signed_doc = sign(draft, private_pem)
|
|
134
|
+
|
|
135
|
+
with open(args.output, "w", encoding="utf-8") as f:
|
|
136
|
+
json.dump(signed_doc, f, indent=2, ensure_ascii=False)
|
|
137
|
+
|
|
138
|
+
print(f"[SUCCESS] Successfully signed and generated {args.output}!")
|
|
139
|
+
except Exception as e:
|
|
140
|
+
print(f"[-] Error: {e}", file=sys.stderr)
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
else:
|
|
143
|
+
parser.print_help()
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
main()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# NOTE: Sync with creduent/utils.py
|
|
2
|
+
import ipaddress
|
|
3
|
+
import socket
|
|
4
|
+
import requests
|
|
5
|
+
from fastapi import HTTPException
|
|
6
|
+
from urllib.parse import urlparse, urljoin
|
|
7
|
+
|
|
8
|
+
def is_private_ip(ip_str: str) -> bool:
|
|
9
|
+
try:
|
|
10
|
+
ip = ipaddress.ip_address(ip_str)
|
|
11
|
+
return ip.is_private or ip.is_loopback or ip.is_link_local
|
|
12
|
+
except ValueError:
|
|
13
|
+
return False
|
|
14
|
+
|
|
15
|
+
def safe_requests_get(url: str, timeout: int = 5, allow_private: bool = False) -> requests.Response:
|
|
16
|
+
"""
|
|
17
|
+
Safe version of requests.get that prevents SSRF by blocking access
|
|
18
|
+
to private IP ranges, including redirect targets.
|
|
19
|
+
"""
|
|
20
|
+
history = []
|
|
21
|
+
current_url = url
|
|
22
|
+
for _ in range(5): # Follow max 5 redirects
|
|
23
|
+
parsed = urlparse(current_url)
|
|
24
|
+
host = parsed.netloc.split(':')[0]
|
|
25
|
+
try:
|
|
26
|
+
ip = socket.gethostbyname(host)
|
|
27
|
+
if not allow_private and is_private_ip(ip):
|
|
28
|
+
raise HTTPException(status_code=400, detail="Access to private IP ranges is blocked.")
|
|
29
|
+
except socket.gaierror:
|
|
30
|
+
# If DNS resolution fails here, let requests handle the connection error
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
response = requests.get(current_url, timeout=timeout, allow_redirects=False)
|
|
34
|
+
if response.is_redirect:
|
|
35
|
+
history.append(response)
|
|
36
|
+
next_url = response.headers.get('location')
|
|
37
|
+
if not next_url:
|
|
38
|
+
break
|
|
39
|
+
current_url = urljoin(current_url, next_url)
|
|
40
|
+
else:
|
|
41
|
+
response.history = history
|
|
42
|
+
return response
|
|
43
|
+
|
|
44
|
+
response = requests.get(current_url, timeout=timeout, allow_redirects=False)
|
|
45
|
+
response.history = history
|
|
46
|
+
return response
|
|
47
|
+
|
|
48
|
+
def load_dotenv():
|
|
49
|
+
"""
|
|
50
|
+
Manually loads .env.local or .env file from the project base directory
|
|
51
|
+
into os.environ for local testing/development.
|
|
52
|
+
"""
|
|
53
|
+
import os
|
|
54
|
+
# Try to find base dir (where .env.local resides)
|
|
55
|
+
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
|
56
|
+
for filename in ['.env.local', '.env']:
|
|
57
|
+
filepath = os.path.join(base_dir, filename)
|
|
58
|
+
if os.path.exists(filepath):
|
|
59
|
+
try:
|
|
60
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
61
|
+
for line in f:
|
|
62
|
+
line = line.strip()
|
|
63
|
+
if not line or line.startswith('#'):
|
|
64
|
+
continue
|
|
65
|
+
if '=' in line:
|
|
66
|
+
key, val = line.split('=', 1)
|
|
67
|
+
key = key.strip()
|
|
68
|
+
val = val.strip()
|
|
69
|
+
# Strip quotes
|
|
70
|
+
if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
|
|
71
|
+
val = val[1:-1]
|
|
72
|
+
# Set and overwrite in environment
|
|
73
|
+
os.environ[key] = val
|
|
74
|
+
except Exception as e:
|
|
75
|
+
print(f"[-] Warning: Failed to load environment file {filename}: {e}")
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
import base64
|
|
5
|
+
import argparse
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
9
|
+
from cryptography.exceptions import InvalidSignature
|
|
10
|
+
|
|
11
|
+
from creduent.crypto import canonicalize
|
|
12
|
+
from creduent.utils import safe_requests_get
|
|
13
|
+
from creduent.exceptions import VerificationError
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class VerifyResult:
|
|
17
|
+
valid: bool
|
|
18
|
+
agent_id: str
|
|
19
|
+
public_key: str
|
|
20
|
+
endpoint: str
|
|
21
|
+
capabilities: List[str]
|
|
22
|
+
error: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
def resolve_target(target: str) -> str:
|
|
25
|
+
"""
|
|
26
|
+
Resolves agent_id, domain, or URL to a fetchable well-known agent.json URL.
|
|
27
|
+
"""
|
|
28
|
+
target = target.strip()
|
|
29
|
+
|
|
30
|
+
# 1. Handle HTTP/HTTPS URLs
|
|
31
|
+
if target.startswith("http://") or target.startswith("https://"):
|
|
32
|
+
from urllib.parse import urlparse, urlunparse
|
|
33
|
+
parsed = urlparse(target)
|
|
34
|
+
if not parsed.path.endswith("/.well-known/agent.json"):
|
|
35
|
+
# Append well-known path if not present
|
|
36
|
+
path = parsed.path.rstrip('/') + "/.well-known/agent.json"
|
|
37
|
+
return urlunparse((parsed.scheme, parsed.netloc, path, parsed.params, parsed.query, parsed.fragment))
|
|
38
|
+
return target
|
|
39
|
+
|
|
40
|
+
# 2. Handle agent:// URIs
|
|
41
|
+
if target.startswith("agent://"):
|
|
42
|
+
from urllib.parse import urlparse
|
|
43
|
+
parsed = urlparse(target)
|
|
44
|
+
namespace = parsed.netloc
|
|
45
|
+
|
|
46
|
+
# Default mapping fallback for testing/reconbot
|
|
47
|
+
if target == "agent://creduent/reconbot":
|
|
48
|
+
return "https://api.idevsec.com/.well-known/agent.json"
|
|
49
|
+
|
|
50
|
+
# Try to resolve domain from Creduent registry
|
|
51
|
+
try:
|
|
52
|
+
registry_url = "https://api.idevsec.com/registry/attest/" + target
|
|
53
|
+
response = safe_requests_get(registry_url, timeout=5)
|
|
54
|
+
if response.status_code == 200:
|
|
55
|
+
attestation = response.json()
|
|
56
|
+
domain = attestation.get("domain")
|
|
57
|
+
if domain:
|
|
58
|
+
return f"https://{domain}/.well-known/agent.json"
|
|
59
|
+
except Exception:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
# Fallback to default namespace resolution
|
|
63
|
+
return f"https://api.{namespace}.ai/.well-known/agent.json"
|
|
64
|
+
|
|
65
|
+
# 3. Handle domain or host name (e.g. "api.idevsec.com")
|
|
66
|
+
scheme = "http" if "localhost" in target or "127.0.0.1" in target else "https"
|
|
67
|
+
return f"{scheme}://{target}/.well-known/agent.json"
|
|
68
|
+
|
|
69
|
+
def verify(target: str | dict) -> VerifyResult:
|
|
70
|
+
"""
|
|
71
|
+
Verify a self-signed agent.json — from URL, agent:// URI, domain string, or dict.
|
|
72
|
+
Returns VerifyResult.
|
|
73
|
+
Raises VerificationError on connection or critical resolution failures.
|
|
74
|
+
"""
|
|
75
|
+
doc = None
|
|
76
|
+
if isinstance(target, dict):
|
|
77
|
+
doc = target
|
|
78
|
+
elif isinstance(target, str):
|
|
79
|
+
resolved_url = resolve_target(target)
|
|
80
|
+
try:
|
|
81
|
+
allow_private = "localhost" in resolved_url or "127.0.0.1" in resolved_url
|
|
82
|
+
response = safe_requests_get(resolved_url, timeout=5, allow_private=allow_private)
|
|
83
|
+
if response.status_code != 200:
|
|
84
|
+
raise VerificationError(f"Failed to fetch agent.json: HTTP status {response.status_code}")
|
|
85
|
+
doc = response.json()
|
|
86
|
+
except Exception as e:
|
|
87
|
+
# Re-raise VerificationError or wrap other errors
|
|
88
|
+
if isinstance(e, VerificationError):
|
|
89
|
+
raise
|
|
90
|
+
err_msg = str(e)
|
|
91
|
+
if hasattr(e, 'detail'):
|
|
92
|
+
err_msg = e.detail
|
|
93
|
+
raise VerificationError(f"Failed to retrieve agent.json: {err_msg}")
|
|
94
|
+
else:
|
|
95
|
+
raise VerificationError("Target must be a dictionary or a string")
|
|
96
|
+
|
|
97
|
+
if not isinstance(doc, dict):
|
|
98
|
+
return VerifyResult(
|
|
99
|
+
valid=False,
|
|
100
|
+
agent_id="",
|
|
101
|
+
public_key="",
|
|
102
|
+
endpoint="",
|
|
103
|
+
capabilities=[],
|
|
104
|
+
error="Parsed document is not a JSON object"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
agent_id = doc.get("agent_id", "")
|
|
108
|
+
public_key_str = doc.get("public_key", "")
|
|
109
|
+
endpoint = doc.get("endpoint", "")
|
|
110
|
+
capabilities = doc.get("capabilities", [])
|
|
111
|
+
|
|
112
|
+
# 1. Structural schema manual validation
|
|
113
|
+
required = ["version", "agent_id", "owner", "public_key", "endpoint", "capabilities", "signature"]
|
|
114
|
+
for field in required:
|
|
115
|
+
if field not in doc:
|
|
116
|
+
return VerifyResult(
|
|
117
|
+
valid=False,
|
|
118
|
+
agent_id=agent_id,
|
|
119
|
+
public_key=public_key_str,
|
|
120
|
+
endpoint=endpoint,
|
|
121
|
+
capabilities=capabilities,
|
|
122
|
+
error=f"Missing required field '{field}' in agent.json"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if doc.get("version") != "1.0":
|
|
126
|
+
return VerifyResult(
|
|
127
|
+
valid=False,
|
|
128
|
+
agent_id=agent_id,
|
|
129
|
+
public_key=public_key_str,
|
|
130
|
+
endpoint=endpoint,
|
|
131
|
+
capabilities=capabilities,
|
|
132
|
+
error=f"Unsupported protocol version: {doc.get('version')}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if not isinstance(capabilities, list):
|
|
136
|
+
return VerifyResult(
|
|
137
|
+
valid=False,
|
|
138
|
+
agent_id=agent_id,
|
|
139
|
+
public_key=public_key_str,
|
|
140
|
+
endpoint=endpoint,
|
|
141
|
+
capabilities=[],
|
|
142
|
+
error="Capabilities field must be a list of strings"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# 2. Cryptographic signature check
|
|
146
|
+
if not public_key_str.startswith("ed25519:"):
|
|
147
|
+
return VerifyResult(
|
|
148
|
+
valid=False,
|
|
149
|
+
agent_id=agent_id,
|
|
150
|
+
public_key=public_key_str,
|
|
151
|
+
endpoint=endpoint,
|
|
152
|
+
capabilities=capabilities,
|
|
153
|
+
error="Unsupported public key format. Only 'ed25519:' prefix is supported."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
pk_b64 = public_key_str.split(":", 1)[1]
|
|
158
|
+
public_key_bytes = base64.b64decode(pk_b64)
|
|
159
|
+
public_key = ed25519.Ed25519PublicKey.from_public_bytes(public_key_bytes)
|
|
160
|
+
|
|
161
|
+
signature_b64 = doc.get("signature")
|
|
162
|
+
signature_bytes = base64.b64decode(signature_b64)
|
|
163
|
+
|
|
164
|
+
# Clone doc and remove signature before canonicalizing
|
|
165
|
+
doc_copy = doc.copy()
|
|
166
|
+
doc_copy.pop("signature", None)
|
|
167
|
+
|
|
168
|
+
canonical_str = canonicalize(doc_copy)
|
|
169
|
+
canonical_bytes = canonical_str.encode('utf-8')
|
|
170
|
+
|
|
171
|
+
public_key.verify(signature_bytes, canonical_bytes)
|
|
172
|
+
except InvalidSignature:
|
|
173
|
+
return VerifyResult(
|
|
174
|
+
valid=False,
|
|
175
|
+
agent_id=agent_id,
|
|
176
|
+
public_key=public_key_str,
|
|
177
|
+
endpoint=endpoint,
|
|
178
|
+
capabilities=capabilities,
|
|
179
|
+
error="Cryptographic signature in agent.json is INVALID."
|
|
180
|
+
)
|
|
181
|
+
except Exception as e:
|
|
182
|
+
return VerifyResult(
|
|
183
|
+
valid=False,
|
|
184
|
+
agent_id=agent_id,
|
|
185
|
+
public_key=public_key_str,
|
|
186
|
+
endpoint=endpoint,
|
|
187
|
+
capabilities=capabilities,
|
|
188
|
+
error=f"Signature verification failed: {e}"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return VerifyResult(
|
|
192
|
+
valid=True,
|
|
193
|
+
agent_id=agent_id,
|
|
194
|
+
public_key=public_key_str,
|
|
195
|
+
endpoint=endpoint,
|
|
196
|
+
capabilities=capabilities,
|
|
197
|
+
error=None
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def main():
|
|
201
|
+
parser = argparse.ArgumentParser(description="Creduent Protocol - agent.json Verification CLI")
|
|
202
|
+
parser.add_argument("target", help="The verification target (domain, URL, agent:// URI, or local JSON path)")
|
|
203
|
+
|
|
204
|
+
args = parser.parse_args()
|
|
205
|
+
|
|
206
|
+
target = args.target
|
|
207
|
+
# Check if target is a local file path
|
|
208
|
+
if os.path.exists(target):
|
|
209
|
+
try:
|
|
210
|
+
with open(target, "r", encoding="utf-8") as f:
|
|
211
|
+
target = json.load(f)
|
|
212
|
+
except Exception as e:
|
|
213
|
+
print(f"[-] Error reading local target file: {e}", file=sys.stderr)
|
|
214
|
+
sys.exit(1)
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
result = verify(target)
|
|
218
|
+
if result.valid:
|
|
219
|
+
print("\n" + "="*50)
|
|
220
|
+
print("[SUCCESS] IDENTITY VERIFIED & CRYPTOGRAPHICALLY VALID")
|
|
221
|
+
print(f"Agent ID: {result.agent_id}")
|
|
222
|
+
print(f"Public Key: {result.public_key}")
|
|
223
|
+
print(f"Endpoint: {result.endpoint}")
|
|
224
|
+
print(f"Capabilities: {', '.join(result.capabilities)}")
|
|
225
|
+
print("="*50)
|
|
226
|
+
sys.exit(0)
|
|
227
|
+
else:
|
|
228
|
+
print("\n" + "="*50)
|
|
229
|
+
print("[FAILED] VERIFICATION FAILED")
|
|
230
|
+
print(f"Error: {result.error}")
|
|
231
|
+
print("="*50)
|
|
232
|
+
sys.exit(1)
|
|
233
|
+
except Exception as e:
|
|
234
|
+
print(f"[-] Error during verification pipeline: {e}", file=sys.stderr)
|
|
235
|
+
sys.exit(1)
|
|
236
|
+
|
|
237
|
+
if __name__ == "__main__":
|
|
238
|
+
main()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: creduent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Creduent Protocol SDK — cryptographic identity for AI agents
|
|
5
|
+
License: MIT
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: cryptography
|
|
12
|
+
Requires-Dist: requests
|
|
13
|
+
Requires-Dist: jcs
|
|
14
|
+
Requires-Dist: fastapi
|
|
15
|
+
|
|
16
|
+
# Creduent Python SDK
|
|
17
|
+
|
|
18
|
+
The official Python SDK for the **Creduent Protocol** — a federated, open trust-verification layer and cryptographic identity infrastructure for autonomous AI agents.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
Install the self-contained package from PyPI with a single command:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install creduent
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quickstart
|
|
29
|
+
|
|
30
|
+
Below is a complete quickstart demonstrating keypair generation, self-signing, verification, registration, and attestation resolution using the SDK.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from creduent import (
|
|
34
|
+
generate_keys,
|
|
35
|
+
sign,
|
|
36
|
+
verify,
|
|
37
|
+
register,
|
|
38
|
+
attest,
|
|
39
|
+
CreduEntError
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
# 1. Generate a new Ed25519 keypair
|
|
44
|
+
private_key_pem, public_key_str = generate_keys()
|
|
45
|
+
print(f"Generated Public Key: {public_key_str}\n")
|
|
46
|
+
|
|
47
|
+
# 2. Sign a draft agent.json document
|
|
48
|
+
draft_document = {
|
|
49
|
+
"agent_id": "agent://creduent/reconbot",
|
|
50
|
+
"owner": "Creduent",
|
|
51
|
+
"public_key": public_key_str,
|
|
52
|
+
"endpoint": "https://api.idevsec.com/recon",
|
|
53
|
+
"capabilities": ["osint", "dns_lookup", "vulnerability_scan"]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
signed_doc = sign(draft_document, private_key_pem)
|
|
57
|
+
print("Signed agent.json:")
|
|
58
|
+
print(signed_doc)
|
|
59
|
+
print()
|
|
60
|
+
|
|
61
|
+
# 3. Verify a self-signed agent.json (from dict, URL, domain, or agent:// URI)
|
|
62
|
+
# Verification with a document dictionary
|
|
63
|
+
result = verify(signed_doc)
|
|
64
|
+
print(f"Self-Signed Verification Result (dict): {result.valid}")
|
|
65
|
+
|
|
66
|
+
# Verification with a live agent URL
|
|
67
|
+
live_result = verify("https://api.idevsec.com/.well-known/agent.json")
|
|
68
|
+
print(f"Live Verification Result (URL): {live_result.valid}")
|
|
69
|
+
print(f"Live Agent ID: {live_result.agent_id}")
|
|
70
|
+
print(f"Live Capabilities: {live_result.capabilities}\n")
|
|
71
|
+
|
|
72
|
+
# 4. Register an agent with the Creduent registry
|
|
73
|
+
# Returns RegisterResult with success (bool), attestation (dict|None), error (str|None)
|
|
74
|
+
reg_result = register(
|
|
75
|
+
agent_id="agent://creduent/reconbot",
|
|
76
|
+
domain="api.idevsec.com",
|
|
77
|
+
agent_json_url="https://api.idevsec.com/.well-known/agent.json"
|
|
78
|
+
)
|
|
79
|
+
print(f"Registration Successful: {reg_result.success}")
|
|
80
|
+
print(f"Attestation: {reg_result.attestation}\n")
|
|
81
|
+
|
|
82
|
+
# 5. Fetch and validate an active attestation for an agent
|
|
83
|
+
# Returns AttestResult with attested (bool), level (str), issued_at, expires_at, error (str|None)
|
|
84
|
+
attest_result = attest("agent://creduent/reconbot")
|
|
85
|
+
print(f"Is Attested: {attest_result.attested}")
|
|
86
|
+
print(f"Attestation Level: {attest_result.level}")
|
|
87
|
+
print(f"Issued At: {attest_result.issued_at}")
|
|
88
|
+
print(f"Expires At: {attest_result.expires_at}\n")
|
|
89
|
+
|
|
90
|
+
except CreduEntError as e:
|
|
91
|
+
print(f"Creduent Protocol Error occurred: {e}")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Protocol Specification
|
|
95
|
+
|
|
96
|
+
For full information on the cryptographic standards, JCS canonicalization, and the federated verification workflows, read the complete [Creduent Protocol Specification](https://github.com/cyberfascinate/creduent).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
setup.py
|
|
4
|
+
creduent/__init__.py
|
|
5
|
+
creduent/attest.py
|
|
6
|
+
creduent/crypto.py
|
|
7
|
+
creduent/exceptions.py
|
|
8
|
+
creduent/register.py
|
|
9
|
+
creduent/sign.py
|
|
10
|
+
creduent/utils.py
|
|
11
|
+
creduent/verify.py
|
|
12
|
+
creduent.egg-info/PKG-INFO
|
|
13
|
+
creduent.egg-info/SOURCES.txt
|
|
14
|
+
creduent.egg-info/dependency_links.txt
|
|
15
|
+
creduent.egg-info/entry_points.txt
|
|
16
|
+
creduent.egg-info/requires.txt
|
|
17
|
+
creduent.egg-info/top_level.txt
|
|
18
|
+
tests/test_sdk.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
creduent
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "creduent"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Creduent Protocol SDK — cryptographic identity for AI agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"cryptography",
|
|
19
|
+
"requests",
|
|
20
|
+
"jcs",
|
|
21
|
+
"fastapi",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
creduent-sign = "creduent.sign:main"
|
|
26
|
+
creduent-verify = "creduent.verify:main"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["."]
|
|
30
|
+
include = ["creduent*"]
|
creduent-0.1.0/setup.cfg
ADDED
creduent-0.1.0/setup.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="creduent",
|
|
5
|
+
version="0.1.0",
|
|
6
|
+
packages=find_packages(),
|
|
7
|
+
install_requires=[
|
|
8
|
+
"cryptography",
|
|
9
|
+
"requests",
|
|
10
|
+
"jcs",
|
|
11
|
+
"fastapi",
|
|
12
|
+
],
|
|
13
|
+
entry_points={
|
|
14
|
+
"console_scripts": [
|
|
15
|
+
"creduent-sign=creduent.sign:main",
|
|
16
|
+
"creduent-verify=creduent.verify:main",
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import unittest
|
|
4
|
+
import base64
|
|
5
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
6
|
+
|
|
7
|
+
# Add the sdk/ folder to path so we can import creduent directly
|
|
8
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
|
9
|
+
|
|
10
|
+
from creduent import (
|
|
11
|
+
generate_keys,
|
|
12
|
+
sign,
|
|
13
|
+
verify,
|
|
14
|
+
attest,
|
|
15
|
+
VerificationError,
|
|
16
|
+
AttestationError
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
class TestCreduentSDK(unittest.TestCase):
|
|
20
|
+
|
|
21
|
+
def test_generate_keys(self):
|
|
22
|
+
"""1. generate_keys() returns valid PEM + ed25519 prefixed public key"""
|
|
23
|
+
private_key_pem, public_key_str = generate_keys()
|
|
24
|
+
|
|
25
|
+
# Verify private key is PEM format
|
|
26
|
+
self.assertTrue(private_key_pem.startswith("-----BEGIN PRIVATE KEY-----"))
|
|
27
|
+
self.assertTrue(private_key_pem.strip().endswith("-----END PRIVATE KEY-----"))
|
|
28
|
+
|
|
29
|
+
# Verify public key format
|
|
30
|
+
self.assertTrue(public_key_str.startswith("ed25519:"))
|
|
31
|
+
pk_b64 = public_key_str.split(":", 1)[1]
|
|
32
|
+
|
|
33
|
+
# Verify it can be base64 decoded
|
|
34
|
+
pk_bytes = base64.b64decode(pk_b64)
|
|
35
|
+
self.assertEqual(len(pk_bytes), 32)
|
|
36
|
+
|
|
37
|
+
def test_sign_document(self):
|
|
38
|
+
"""2. sign() produces a dict with a valid signature field"""
|
|
39
|
+
private_key_pem, public_key_str = generate_keys()
|
|
40
|
+
|
|
41
|
+
draft = {
|
|
42
|
+
"agent_id": "agent://creduent/reconbot",
|
|
43
|
+
"owner": "Creduent",
|
|
44
|
+
"public_key": public_key_str,
|
|
45
|
+
"endpoint": "https://api.idevsec.com/recon",
|
|
46
|
+
"capabilities": ["osint", "dns_lookup", "vulnerability_scan"]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
signed_doc = sign(draft, private_key_pem)
|
|
50
|
+
|
|
51
|
+
self.assertIn("signature", signed_doc)
|
|
52
|
+
self.assertIn("issued_at", signed_doc)
|
|
53
|
+
self.assertEqual(signed_doc["version"], "1.0")
|
|
54
|
+
|
|
55
|
+
# Verify the signature field is non-empty base64
|
|
56
|
+
sig_b64 = signed_doc["signature"]
|
|
57
|
+
sig_bytes = base64.b64decode(sig_b64)
|
|
58
|
+
self.assertEqual(len(sig_bytes), 64)
|
|
59
|
+
|
|
60
|
+
def test_verify_live_endpoint(self):
|
|
61
|
+
"""3. verify() on the live endpoint returns valid=True"""
|
|
62
|
+
target = "https://api.idevsec.com/.well-known/agent.json"
|
|
63
|
+
try:
|
|
64
|
+
result = verify(target)
|
|
65
|
+
self.assertTrue(result.valid)
|
|
66
|
+
self.assertIsNone(result.error)
|
|
67
|
+
self.assertEqual(result.agent_id, "agent://creduent/reconbot")
|
|
68
|
+
except VerificationError as e:
|
|
69
|
+
# If the network or live endpoint is completely unreachable, skip or print warning
|
|
70
|
+
print(f"\n[WARNING] Live verification test skipped/failed due to network: {e}")
|
|
71
|
+
|
|
72
|
+
def test_verify_tampered_dict(self):
|
|
73
|
+
"""4. verify() on a tampered dict returns valid=False"""
|
|
74
|
+
private_key_pem, public_key_str = generate_keys()
|
|
75
|
+
|
|
76
|
+
draft = {
|
|
77
|
+
"agent_id": "agent://creduent/reconbot",
|
|
78
|
+
"owner": "Creduent",
|
|
79
|
+
"public_key": public_key_str,
|
|
80
|
+
"endpoint": "https://api.idevsec.com/recon",
|
|
81
|
+
"capabilities": ["osint", "dns_lookup", "vulnerability_scan"]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
signed_doc = sign(draft, private_key_pem)
|
|
85
|
+
|
|
86
|
+
# 4a. Verify untampered dict is valid
|
|
87
|
+
res_ok = verify(signed_doc)
|
|
88
|
+
self.assertTrue(res_ok.valid)
|
|
89
|
+
|
|
90
|
+
# 4b. Tamper with a field
|
|
91
|
+
tampered_doc = signed_doc.copy()
|
|
92
|
+
tampered_doc["owner"] = "Not Creduent"
|
|
93
|
+
|
|
94
|
+
res_tampered = verify(tampered_doc)
|
|
95
|
+
self.assertFalse(res_tampered.valid)
|
|
96
|
+
self.assertIsNotNone(res_tampered.error)
|
|
97
|
+
|
|
98
|
+
def test_attest_live(self):
|
|
99
|
+
"""5. attest() against https://api.idevsec.com returns a valid AttestResult"""
|
|
100
|
+
try:
|
|
101
|
+
result = attest("agent://creduent/reconbot", "https://api.idevsec.com")
|
|
102
|
+
# The agent might or might not be currently registered in the database,
|
|
103
|
+
# but the result should be a valid AttestResult dataclass
|
|
104
|
+
self.assertIn(result.attested, [True, False])
|
|
105
|
+
if result.attested:
|
|
106
|
+
self.assertEqual(result.level, "verified")
|
|
107
|
+
self.assertIsNotNone(result.issued_at)
|
|
108
|
+
self.assertIsNotNone(result.expires_at)
|
|
109
|
+
self.assertIsNone(result.error)
|
|
110
|
+
else:
|
|
111
|
+
self.assertIsNotNone(result.error)
|
|
112
|
+
except AttestationError as e:
|
|
113
|
+
print(f"\n[WARNING] Live attestation test skipped/failed due to network: {e}")
|
|
114
|
+
|
|
115
|
+
if __name__ == '__main__':
|
|
116
|
+
unittest.main()
|