certivu 1.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.
- certivu-1.1.0/PKG-INFO +118 -0
- certivu-1.1.0/README.md +88 -0
- certivu-1.1.0/certivu/__init__.py +51 -0
- certivu-1.1.0/certivu/_crypto.py +82 -0
- certivu-1.1.0/certivu/_exceptions.py +26 -0
- certivu-1.1.0/certivu/_models.py +72 -0
- certivu-1.1.0/certivu/client.py +390 -0
- certivu-1.1.0/certivu.egg-info/PKG-INFO +118 -0
- certivu-1.1.0/certivu.egg-info/SOURCES.txt +13 -0
- certivu-1.1.0/certivu.egg-info/dependency_links.txt +1 -0
- certivu-1.1.0/certivu.egg-info/requires.txt +11 -0
- certivu-1.1.0/certivu.egg-info/top_level.txt +1 -0
- certivu-1.1.0/pyproject.toml +45 -0
- certivu-1.1.0/setup.cfg +4 -0
- certivu-1.1.0/tests/test_client.py +244 -0
certivu-1.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: certivu
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Python SDK for Certivu — quantum-resistant trust infrastructure for AI-generated content
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Project-URL: Homepage, https://certivu.ai
|
|
7
|
+
Project-URL: Documentation, https://docs.certivu.ai
|
|
8
|
+
Project-URL: API Reference, https://api.certivu.ai/docs
|
|
9
|
+
Keywords: certivu,ai,provenance,ml-dsa,dilithium,post-quantum,verification,watermark
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Security :: Cryptography
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Requires-Dist: pydantic>=2.0
|
|
23
|
+
Provides-Extra: signing
|
|
24
|
+
Requires-Dist: dilithium-py>=0.0.6; extra == "signing"
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
28
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
29
|
+
Requires-Dist: dilithium-py>=0.0.6; extra == "dev"
|
|
30
|
+
|
|
31
|
+
# certivu-python
|
|
32
|
+
|
|
33
|
+
Python SDK for [Certivu](https://certivu.ai) — quantum-resistant trust infrastructure for AI-generated content.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install certivu # verify-only (httpx + pydantic)
|
|
39
|
+
pip install certivu[signing] # + ML-DSA signing (dilithium-py)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from certivu import CertivuClient
|
|
46
|
+
|
|
47
|
+
client = CertivuClient(
|
|
48
|
+
api_key="ctv_key_abc123",
|
|
49
|
+
generator_id="your-generator-uuid",
|
|
50
|
+
private_key="base64-ml-dsa-private-key", # requires certivu[signing]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Sign AI-generated content
|
|
54
|
+
result = client.sign(content=image_bytes, model="stable-diffusion-xl")
|
|
55
|
+
print(result.token) # ctv_7f3kx9mq2... — embed in XMP metadata
|
|
56
|
+
|
|
57
|
+
# Verify — no API key needed, always free
|
|
58
|
+
result = client.verify(content=image_bytes)
|
|
59
|
+
if result.authentic and result.confidence == "high":
|
|
60
|
+
print(result.provenance.org, result.provenance.signed_at)
|
|
61
|
+
|
|
62
|
+
# Verify without re-uploading the image
|
|
63
|
+
status = client.get_token_status("ctv_7f3kx9mq2...")
|
|
64
|
+
|
|
65
|
+
# Batch verify
|
|
66
|
+
results = client.verify_batch([
|
|
67
|
+
{"content": image1_bytes},
|
|
68
|
+
{"content": image2_bytes, "token": "ctv_..."},
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
# Audit log
|
|
72
|
+
page = client.get_audit_log(page=1, limit=50)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Async
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from certivu import AsyncCertivuClient
|
|
79
|
+
|
|
80
|
+
async with AsyncCertivuClient(api_key="ctv_key_abc123") as client:
|
|
81
|
+
result = await client.verify(content=image_bytes)
|
|
82
|
+
results = await client.verify_batch([{"content": img} for img in images])
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Signing setup
|
|
86
|
+
|
|
87
|
+
1. Generate a keypair via the dashboard or API:
|
|
88
|
+
```python
|
|
89
|
+
keypair = client.generate_keypair()
|
|
90
|
+
# Save keypair.private_key immediately — never stored by Certivu
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
2. Register the generator in your dashboard with the public key.
|
|
94
|
+
|
|
95
|
+
3. Sign content:
|
|
96
|
+
```python
|
|
97
|
+
result = client.sign(
|
|
98
|
+
content=image_bytes,
|
|
99
|
+
model="stable-diffusion-xl",
|
|
100
|
+
generator_id=keypair_id,
|
|
101
|
+
private_key=keypair.private_key,
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Confidence levels
|
|
106
|
+
|
|
107
|
+
| Confidence | Meaning |
|
|
108
|
+
|------------|---------|
|
|
109
|
+
| `high` | Watermark ✓ + Record ✓ + Signature ✓ — full chain intact |
|
|
110
|
+
| `medium` | Record ✓ + Signature ✓ — re-uploaded without watermark |
|
|
111
|
+
| `low` | Partial signals — something is off |
|
|
112
|
+
| `none` | Not signed by Certivu — no claim either way |
|
|
113
|
+
|
|
114
|
+
## Links
|
|
115
|
+
|
|
116
|
+
- [API Reference](https://api.certivu.ai/docs)
|
|
117
|
+
- [Documentation](https://docs.certivu.ai)
|
|
118
|
+
- [Pricing](https://certivu.ai/pricing)
|
certivu-1.1.0/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# certivu-python
|
|
2
|
+
|
|
3
|
+
Python SDK for [Certivu](https://certivu.ai) — quantum-resistant trust infrastructure for AI-generated content.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install certivu # verify-only (httpx + pydantic)
|
|
9
|
+
pip install certivu[signing] # + ML-DSA signing (dilithium-py)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quick start
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from certivu import CertivuClient
|
|
16
|
+
|
|
17
|
+
client = CertivuClient(
|
|
18
|
+
api_key="ctv_key_abc123",
|
|
19
|
+
generator_id="your-generator-uuid",
|
|
20
|
+
private_key="base64-ml-dsa-private-key", # requires certivu[signing]
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Sign AI-generated content
|
|
24
|
+
result = client.sign(content=image_bytes, model="stable-diffusion-xl")
|
|
25
|
+
print(result.token) # ctv_7f3kx9mq2... — embed in XMP metadata
|
|
26
|
+
|
|
27
|
+
# Verify — no API key needed, always free
|
|
28
|
+
result = client.verify(content=image_bytes)
|
|
29
|
+
if result.authentic and result.confidence == "high":
|
|
30
|
+
print(result.provenance.org, result.provenance.signed_at)
|
|
31
|
+
|
|
32
|
+
# Verify without re-uploading the image
|
|
33
|
+
status = client.get_token_status("ctv_7f3kx9mq2...")
|
|
34
|
+
|
|
35
|
+
# Batch verify
|
|
36
|
+
results = client.verify_batch([
|
|
37
|
+
{"content": image1_bytes},
|
|
38
|
+
{"content": image2_bytes, "token": "ctv_..."},
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
# Audit log
|
|
42
|
+
page = client.get_audit_log(page=1, limit=50)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Async
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from certivu import AsyncCertivuClient
|
|
49
|
+
|
|
50
|
+
async with AsyncCertivuClient(api_key="ctv_key_abc123") as client:
|
|
51
|
+
result = await client.verify(content=image_bytes)
|
|
52
|
+
results = await client.verify_batch([{"content": img} for img in images])
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Signing setup
|
|
56
|
+
|
|
57
|
+
1. Generate a keypair via the dashboard or API:
|
|
58
|
+
```python
|
|
59
|
+
keypair = client.generate_keypair()
|
|
60
|
+
# Save keypair.private_key immediately — never stored by Certivu
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
2. Register the generator in your dashboard with the public key.
|
|
64
|
+
|
|
65
|
+
3. Sign content:
|
|
66
|
+
```python
|
|
67
|
+
result = client.sign(
|
|
68
|
+
content=image_bytes,
|
|
69
|
+
model="stable-diffusion-xl",
|
|
70
|
+
generator_id=keypair_id,
|
|
71
|
+
private_key=keypair.private_key,
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Confidence levels
|
|
76
|
+
|
|
77
|
+
| Confidence | Meaning |
|
|
78
|
+
|------------|---------|
|
|
79
|
+
| `high` | Watermark ✓ + Record ✓ + Signature ✓ — full chain intact |
|
|
80
|
+
| `medium` | Record ✓ + Signature ✓ — re-uploaded without watermark |
|
|
81
|
+
| `low` | Partial signals — something is off |
|
|
82
|
+
| `none` | Not signed by Certivu — no claim either way |
|
|
83
|
+
|
|
84
|
+
## Links
|
|
85
|
+
|
|
86
|
+
- [API Reference](https://api.certivu.ai/docs)
|
|
87
|
+
- [Documentation](https://docs.certivu.ai)
|
|
88
|
+
- [Pricing](https://certivu.ai/pricing)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
certivu — Python SDK for Certivu quantum-resistant AI content trust infrastructure.
|
|
3
|
+
|
|
4
|
+
Quick start::
|
|
5
|
+
|
|
6
|
+
from certivu import CertivuClient
|
|
7
|
+
|
|
8
|
+
client = CertivuClient(
|
|
9
|
+
api_key="ctv_key_abc123",
|
|
10
|
+
generator_id="your-generator-uuid",
|
|
11
|
+
private_key="base64-ml-dsa-private-key", # pip install certivu[signing]
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# Sign AI-generated content
|
|
15
|
+
result = client.sign(content=image_bytes, model="stable-diffusion-xl")
|
|
16
|
+
print(result.token) # embed in XMP metadata
|
|
17
|
+
|
|
18
|
+
# Verify (no auth needed)
|
|
19
|
+
result = client.verify(content=image_bytes)
|
|
20
|
+
if result.authentic and result.confidence == "high":
|
|
21
|
+
print(result.provenance.org, result.provenance.signed_at)
|
|
22
|
+
|
|
23
|
+
Async::
|
|
24
|
+
|
|
25
|
+
from certivu import AsyncCertivuClient
|
|
26
|
+
|
|
27
|
+
async with AsyncCertivuClient(api_key="ctv_key_abc123") as client:
|
|
28
|
+
result = await client.verify(content=image_bytes)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from .client import AsyncCertivuClient, CertivuClient
|
|
32
|
+
from ._exceptions import AuthError, CertivuError, NotFoundError, QuotaError, ValidationError
|
|
33
|
+
from ._models import AuditEvent, AuditPage, GeneratorKeypair, SignResult, TokenStatus, VerifyResult
|
|
34
|
+
|
|
35
|
+
__version__ = "1.1.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"CertivuClient",
|
|
39
|
+
"AsyncCertivuClient",
|
|
40
|
+
"CertivuError",
|
|
41
|
+
"AuthError",
|
|
42
|
+
"QuotaError",
|
|
43
|
+
"NotFoundError",
|
|
44
|
+
"ValidationError",
|
|
45
|
+
"VerifyResult",
|
|
46
|
+
"SignResult",
|
|
47
|
+
"AuditEvent",
|
|
48
|
+
"AuditPage",
|
|
49
|
+
"GeneratorKeypair",
|
|
50
|
+
"TokenStatus",
|
|
51
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Crypto helpers — SHA-3 hashing and ML-DSA-65 signing.
|
|
3
|
+
|
|
4
|
+
ML-DSA signing requires `dilithium-py`:
|
|
5
|
+
pip install certivu[signing]
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import uuid
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def sha3_256_hash(content: bytes | str) -> str:
|
|
17
|
+
"""Return sha3-256:<hex> hash of content, matching the server's format."""
|
|
18
|
+
if isinstance(content, str):
|
|
19
|
+
content = content.encode("utf-8")
|
|
20
|
+
digest = hashlib.sha3_256(content).hexdigest()
|
|
21
|
+
return f"sha3-256:{digest}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def canonical_json(obj: dict) -> str:
|
|
25
|
+
"""Produce canonical JSON with sorted keys and no extra whitespace.
|
|
26
|
+
|
|
27
|
+
Matches the TypeScript implementation:
|
|
28
|
+
JSON.stringify(obj, Object.keys(obj).sort())
|
|
29
|
+
"""
|
|
30
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ml_dsa_sign(private_key_b64: str, message: str) -> str:
|
|
34
|
+
"""Sign a message with an ML-DSA-65 private key.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
private_key_b64: Base64-encoded ML-DSA-65 private key (from Certivu dashboard
|
|
38
|
+
or generate_keypair()).
|
|
39
|
+
message: UTF-8 string to sign (typically canonical JSON).
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Base64-encoded ML-DSA signature.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
ImportError: if dilithium-py is not installed.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
from dilithium_py.dilithium import Dilithium3 # type: ignore[import]
|
|
49
|
+
except ImportError as e:
|
|
50
|
+
raise ImportError(
|
|
51
|
+
"ML-DSA signing requires dilithium-py.\n"
|
|
52
|
+
"Install it with: pip install certivu[signing]"
|
|
53
|
+
) from e
|
|
54
|
+
|
|
55
|
+
sk = base64.b64decode(private_key_b64)
|
|
56
|
+
sig: bytes = Dilithium3.sign(sk, message.encode("utf-8"))
|
|
57
|
+
return base64.b64encode(sig).decode("ascii")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def ml_dsa_generate_keypair() -> tuple[str, str]:
|
|
61
|
+
"""Generate a fresh ML-DSA-65 keypair locally.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
(public_key_b64, private_key_b64) — both base64-encoded.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ImportError: if dilithium-py is not installed.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
from dilithium_py.dilithium import Dilithium3 # type: ignore[import]
|
|
71
|
+
except ImportError as e:
|
|
72
|
+
raise ImportError(
|
|
73
|
+
"Key generation requires dilithium-py.\n"
|
|
74
|
+
"Install it with: pip install certivu[signing]"
|
|
75
|
+
) from e
|
|
76
|
+
|
|
77
|
+
pk, sk = Dilithium3.keygen()
|
|
78
|
+
return base64.b64encode(pk).decode("ascii"), base64.b64encode(sk).decode("ascii")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def new_watermark_id() -> str:
|
|
82
|
+
return str(uuid.uuid4())
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
class CertivuError(Exception):
|
|
2
|
+
"""Base exception for all Certivu SDK errors."""
|
|
3
|
+
|
|
4
|
+
def __init__(self, message: str, status_code: int | None = None) -> None:
|
|
5
|
+
super().__init__(message)
|
|
6
|
+
self.status_code = status_code
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthError(CertivuError):
|
|
10
|
+
"""Raised on 401 — invalid or missing API key / session token."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class QuotaError(CertivuError):
|
|
14
|
+
"""Raised on 402 — signing quota exhausted on the free plan."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str, upgrade_url: str | None = None) -> None:
|
|
17
|
+
super().__init__(message, status_code=402)
|
|
18
|
+
self.upgrade_url = upgrade_url
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class NotFoundError(CertivuError):
|
|
22
|
+
"""Raised on 404."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ValidationError(CertivuError):
|
|
26
|
+
"""Raised on 400 — request body failed server-side validation."""
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Signals(BaseModel):
|
|
9
|
+
watermark_found: bool
|
|
10
|
+
record_found: bool
|
|
11
|
+
signature_valid: bool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Provenance(BaseModel):
|
|
15
|
+
org: str
|
|
16
|
+
model: str
|
|
17
|
+
signed_at: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class VerifyResult(BaseModel):
|
|
21
|
+
authentic: bool
|
|
22
|
+
tampered: bool
|
|
23
|
+
confidence: Literal["high", "medium", "low", "none"]
|
|
24
|
+
token_source: Literal["provided", "xmp", "watermark"] | None = None
|
|
25
|
+
signals: Signals
|
|
26
|
+
provenance: Provenance | None = None
|
|
27
|
+
reason: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SignResult(BaseModel):
|
|
31
|
+
record_id: str
|
|
32
|
+
token: str
|
|
33
|
+
deduplicated: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BatchSignResult(BaseModel):
|
|
37
|
+
results: list[SignResult | dict[str, Any]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuditEvent(BaseModel):
|
|
41
|
+
event_id: str
|
|
42
|
+
org_id: str
|
|
43
|
+
type: Literal["sign", "verify", "key_created", "key_revoked"]
|
|
44
|
+
timestamp: str
|
|
45
|
+
metadata: dict[str, Any] = {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AuditPage(BaseModel):
|
|
49
|
+
events: list[AuditEvent]
|
|
50
|
+
total: int
|
|
51
|
+
page: int
|
|
52
|
+
limit: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PublicKey(BaseModel):
|
|
56
|
+
kid: str
|
|
57
|
+
algorithm: str
|
|
58
|
+
key: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GeneratorKeypair(BaseModel):
|
|
62
|
+
kid: str
|
|
63
|
+
public_key: str
|
|
64
|
+
private_key: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TokenStatus(BaseModel):
|
|
68
|
+
record_id: str
|
|
69
|
+
org_id: str
|
|
70
|
+
model: str
|
|
71
|
+
signed_at: str
|
|
72
|
+
generator_status: Literal["active", "revoked"]
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""Certivu sync and async clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._crypto import (
|
|
11
|
+
canonical_json,
|
|
12
|
+
ml_dsa_sign,
|
|
13
|
+
new_watermark_id,
|
|
14
|
+
sha3_256_hash,
|
|
15
|
+
)
|
|
16
|
+
from ._exceptions import AuthError, CertivuError, NotFoundError, QuotaError, ValidationError
|
|
17
|
+
from ._models import AuditPage, GeneratorKeypair, SignResult, TokenStatus, VerifyResult
|
|
18
|
+
|
|
19
|
+
_DEFAULT_BASE_URL = "https://api.certivu.ai"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
23
|
+
if response.status_code < 400:
|
|
24
|
+
return
|
|
25
|
+
try:
|
|
26
|
+
body: dict[str, Any] = response.json()
|
|
27
|
+
except Exception:
|
|
28
|
+
body = {}
|
|
29
|
+
|
|
30
|
+
message = body.get("message") or body.get("error") or f"HTTP {response.status_code}"
|
|
31
|
+
code = response.status_code
|
|
32
|
+
|
|
33
|
+
if code == 401:
|
|
34
|
+
raise AuthError(message, status_code=code)
|
|
35
|
+
if code == 402:
|
|
36
|
+
raise QuotaError(message, upgrade_url=body.get("upgrade_url"))
|
|
37
|
+
if code == 404:
|
|
38
|
+
raise NotFoundError(message, status_code=code)
|
|
39
|
+
if code == 400:
|
|
40
|
+
raise ValidationError(message, status_code=code)
|
|
41
|
+
raise CertivuError(message, status_code=code)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _build_sign_payload(
|
|
45
|
+
content: bytes | str,
|
|
46
|
+
model: str,
|
|
47
|
+
generator_id: str,
|
|
48
|
+
private_key: str,
|
|
49
|
+
watermark_id: str | None = None,
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
if isinstance(content, str):
|
|
52
|
+
content = content.encode("utf-8")
|
|
53
|
+
|
|
54
|
+
content_hash = sha3_256_hash(content)
|
|
55
|
+
wm_id = watermark_id or new_watermark_id()
|
|
56
|
+
signed_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
57
|
+
|
|
58
|
+
signed_payload = {
|
|
59
|
+
"generator_id": generator_id,
|
|
60
|
+
"model": model,
|
|
61
|
+
"content_hash": content_hash,
|
|
62
|
+
"watermark_id": wm_id,
|
|
63
|
+
"signed_at": signed_at,
|
|
64
|
+
}
|
|
65
|
+
signature = ml_dsa_sign(private_key, canonical_json(signed_payload))
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
"watermark_id": wm_id,
|
|
69
|
+
"generator_id": generator_id,
|
|
70
|
+
"model": model,
|
|
71
|
+
"content_hash": content_hash,
|
|
72
|
+
"signature": signature,
|
|
73
|
+
"signed_payload": signed_payload,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class CertivuClient:
|
|
78
|
+
"""Synchronous Certivu client.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
api_key: Your ``ctv_key_...`` API key.
|
|
82
|
+
base_url: Override the API base URL (default: https://api.certivu.ai).
|
|
83
|
+
generator_id: Default generator ID used for ``sign()``.
|
|
84
|
+
private_key: Default base64-encoded ML-DSA private key used for ``sign()``.
|
|
85
|
+
timeout: HTTP timeout in seconds (default 30).
|
|
86
|
+
|
|
87
|
+
Example::
|
|
88
|
+
|
|
89
|
+
from certivu import CertivuClient
|
|
90
|
+
|
|
91
|
+
client = CertivuClient(
|
|
92
|
+
api_key="ctv_key_abc123",
|
|
93
|
+
generator_id="...",
|
|
94
|
+
private_key="...",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
result = client.sign(content=image_bytes, model="stable-diffusion-xl")
|
|
98
|
+
print(result.token)
|
|
99
|
+
|
|
100
|
+
result = client.verify(content=image_bytes)
|
|
101
|
+
print(result.authentic, result.confidence)
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
api_key: str,
|
|
107
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
108
|
+
generator_id: str | None = None,
|
|
109
|
+
private_key: str | None = None,
|
|
110
|
+
timeout: float = 30.0,
|
|
111
|
+
) -> None:
|
|
112
|
+
self._api_key = api_key
|
|
113
|
+
self._base_url = base_url.rstrip("/")
|
|
114
|
+
self._generator_id = generator_id
|
|
115
|
+
self._private_key = private_key
|
|
116
|
+
self._client = httpx.Client(
|
|
117
|
+
base_url=self._base_url,
|
|
118
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
119
|
+
timeout=timeout,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def __enter__(self) -> CertivuClient:
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def __exit__(self, *args: Any) -> None:
|
|
126
|
+
self._client.close()
|
|
127
|
+
|
|
128
|
+
def close(self) -> None:
|
|
129
|
+
self._client.close()
|
|
130
|
+
|
|
131
|
+
# ── Core API ─────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
def sign(
|
|
134
|
+
self,
|
|
135
|
+
content: bytes | str,
|
|
136
|
+
model: str,
|
|
137
|
+
generator_id: str | None = None,
|
|
138
|
+
private_key: str | None = None,
|
|
139
|
+
watermark_id: str | None = None,
|
|
140
|
+
) -> SignResult:
|
|
141
|
+
"""Hash, sign, and submit a provenance record for AI-generated content.
|
|
142
|
+
|
|
143
|
+
Requires ``certivu[signing]`` (dilithium-py) to be installed.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
content: Raw image bytes or UTF-8 string.
|
|
147
|
+
model: Name of the AI model that generated the content, e.g. ``"stable-diffusion-xl"``.
|
|
148
|
+
generator_id: Overrides the default set in the constructor.
|
|
149
|
+
private_key: Base64-encoded ML-DSA private key. Overrides the constructor default.
|
|
150
|
+
watermark_id: Supply an existing watermark ID (optional — generated automatically if omitted).
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
:class:`SignResult` with ``token`` and ``record_id``.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
ImportError: if dilithium-py is not installed.
|
|
157
|
+
AuthError: on invalid API key.
|
|
158
|
+
QuotaError: when the free tier signing limit is reached.
|
|
159
|
+
"""
|
|
160
|
+
gid = generator_id or self._generator_id
|
|
161
|
+
pk = private_key or self._private_key
|
|
162
|
+
if not gid:
|
|
163
|
+
raise ValueError("generator_id is required (pass it here or set it in the constructor)")
|
|
164
|
+
if not pk:
|
|
165
|
+
raise ValueError("private_key is required (pass it here or set it in the constructor)")
|
|
166
|
+
|
|
167
|
+
payload = _build_sign_payload(content, model, gid, pk, watermark_id)
|
|
168
|
+
response = self._client.post("/v1/records", json=payload)
|
|
169
|
+
_raise_for_status(response)
|
|
170
|
+
return SignResult.model_validate(response.json())
|
|
171
|
+
|
|
172
|
+
def sign_batch(
|
|
173
|
+
self,
|
|
174
|
+
items: list[dict[str, Any]],
|
|
175
|
+
generator_id: str | None = None,
|
|
176
|
+
private_key: str | None = None,
|
|
177
|
+
) -> list[dict[str, Any]]:
|
|
178
|
+
"""Sign and submit up to 50 records in one request (HTTP 207 multi-status).
|
|
179
|
+
|
|
180
|
+
Each item in ``items`` must have ``content`` and ``model`` keys; optionally
|
|
181
|
+
``watermark_id``. Uses the generator / private key from the constructor by default.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
List of per-item results (dicts). Check each for ``token`` or ``error``.
|
|
185
|
+
"""
|
|
186
|
+
gid = generator_id or self._generator_id
|
|
187
|
+
pk = private_key or self._private_key
|
|
188
|
+
if not gid:
|
|
189
|
+
raise ValueError("generator_id is required")
|
|
190
|
+
if not pk:
|
|
191
|
+
raise ValueError("private_key is required")
|
|
192
|
+
|
|
193
|
+
records = [
|
|
194
|
+
_build_sign_payload(
|
|
195
|
+
item["content"],
|
|
196
|
+
item["model"],
|
|
197
|
+
gid,
|
|
198
|
+
pk,
|
|
199
|
+
item.get("watermark_id"),
|
|
200
|
+
)
|
|
201
|
+
for item in items
|
|
202
|
+
]
|
|
203
|
+
response = self._client.post("/v1/records/batch", json={"records": records})
|
|
204
|
+
_raise_for_status(response)
|
|
205
|
+
return response.json().get("results", [])
|
|
206
|
+
|
|
207
|
+
def verify(self, content: bytes, token: str | None = None) -> VerifyResult:
|
|
208
|
+
"""Verify AI-generated content authenticity.
|
|
209
|
+
|
|
210
|
+
No API key is required — verification is always free. However, if the client
|
|
211
|
+
was instantiated with an API key it is forwarded anyway (harmless).
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
content: Raw image bytes.
|
|
215
|
+
token: Optional ``ctv_`` token. Extracted from XMP metadata or frequency-domain
|
|
216
|
+
watermark automatically if not provided.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
:class:`VerifyResult` — check ``authentic``, ``confidence``, and ``provenance``.
|
|
220
|
+
"""
|
|
221
|
+
files: dict[str, Any] = {"content": ("content", content, "application/octet-stream")}
|
|
222
|
+
data = {"token": token} if token else {}
|
|
223
|
+
response = self._client.post("/v1/verify", files=files, data=data)
|
|
224
|
+
_raise_for_status(response)
|
|
225
|
+
return VerifyResult.model_validate(response.json())
|
|
226
|
+
|
|
227
|
+
def verify_batch(self, items: list[dict[str, Any]]) -> list[VerifyResult]:
|
|
228
|
+
"""Verify up to 50 items in one request.
|
|
229
|
+
|
|
230
|
+
Each item must have a ``content`` key (``bytes``) and optionally a ``token`` key.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
List of :class:`VerifyResult` in the same order as ``items``.
|
|
234
|
+
"""
|
|
235
|
+
import base64
|
|
236
|
+
|
|
237
|
+
payload_items = []
|
|
238
|
+
for item in items:
|
|
239
|
+
c = item["content"]
|
|
240
|
+
entry: dict[str, Any] = {"content": base64.b64encode(c).decode("ascii")}
|
|
241
|
+
if item.get("token"):
|
|
242
|
+
entry["token"] = item["token"]
|
|
243
|
+
payload_items.append(entry)
|
|
244
|
+
|
|
245
|
+
response = self._client.post("/v1/verify/batch", json={"items": payload_items})
|
|
246
|
+
_raise_for_status(response)
|
|
247
|
+
return [VerifyResult.model_validate(r) for r in response.json().get("results", [])]
|
|
248
|
+
|
|
249
|
+
def get_token_status(self, token: str) -> TokenStatus:
|
|
250
|
+
"""Lightweight CDN-cacheable token status lookup — no image upload required."""
|
|
251
|
+
response = self._client.get(f"/v1/verify/status/{token}")
|
|
252
|
+
_raise_for_status(response)
|
|
253
|
+
return TokenStatus.model_validate(response.json())
|
|
254
|
+
|
|
255
|
+
def get_audit_log(self, page: int = 1, limit: int = 20) -> AuditPage:
|
|
256
|
+
"""Fetch a page of audit events for your org."""
|
|
257
|
+
response = self._client.get("/v1/audit", params={"page": page, "limit": limit})
|
|
258
|
+
_raise_for_status(response)
|
|
259
|
+
return AuditPage.model_validate(response.json())
|
|
260
|
+
|
|
261
|
+
def generate_keypair(self) -> GeneratorKeypair:
|
|
262
|
+
"""Generate a fresh ML-DSA-65 keypair via the API.
|
|
263
|
+
|
|
264
|
+
The private key is returned **once** and never stored by Certivu.
|
|
265
|
+
Save it immediately — it cannot be recovered.
|
|
266
|
+
"""
|
|
267
|
+
response = self._client.post("/v1/generators/keypair")
|
|
268
|
+
_raise_for_status(response)
|
|
269
|
+
return GeneratorKeypair.model_validate(response.json())
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class AsyncCertivuClient:
|
|
273
|
+
"""Async version of :class:`CertivuClient` — use with ``await`` / ``async with``.
|
|
274
|
+
|
|
275
|
+
Example::
|
|
276
|
+
|
|
277
|
+
async with AsyncCertivuClient(api_key="ctv_key_abc123") as client:
|
|
278
|
+
result = await client.verify(content=image_bytes)
|
|
279
|
+
"""
|
|
280
|
+
|
|
281
|
+
def __init__(
|
|
282
|
+
self,
|
|
283
|
+
api_key: str,
|
|
284
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
285
|
+
generator_id: str | None = None,
|
|
286
|
+
private_key: str | None = None,
|
|
287
|
+
timeout: float = 30.0,
|
|
288
|
+
) -> None:
|
|
289
|
+
self._api_key = api_key
|
|
290
|
+
self._base_url = base_url.rstrip("/")
|
|
291
|
+
self._generator_id = generator_id
|
|
292
|
+
self._private_key = private_key
|
|
293
|
+
self._client = httpx.AsyncClient(
|
|
294
|
+
base_url=self._base_url,
|
|
295
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
296
|
+
timeout=timeout,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
async def __aenter__(self) -> AsyncCertivuClient:
|
|
300
|
+
return self
|
|
301
|
+
|
|
302
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
303
|
+
await self._client.aclose()
|
|
304
|
+
|
|
305
|
+
async def aclose(self) -> None:
|
|
306
|
+
await self._client.aclose()
|
|
307
|
+
|
|
308
|
+
async def sign(
|
|
309
|
+
self,
|
|
310
|
+
content: bytes | str,
|
|
311
|
+
model: str,
|
|
312
|
+
generator_id: str | None = None,
|
|
313
|
+
private_key: str | None = None,
|
|
314
|
+
watermark_id: str | None = None,
|
|
315
|
+
) -> SignResult:
|
|
316
|
+
"""Async variant of :meth:`CertivuClient.sign`."""
|
|
317
|
+
gid = generator_id or self._generator_id
|
|
318
|
+
pk = private_key or self._private_key
|
|
319
|
+
if not gid:
|
|
320
|
+
raise ValueError("generator_id is required")
|
|
321
|
+
if not pk:
|
|
322
|
+
raise ValueError("private_key is required")
|
|
323
|
+
|
|
324
|
+
payload = _build_sign_payload(content, model, gid, pk, watermark_id)
|
|
325
|
+
response = await self._client.post("/v1/records", json=payload)
|
|
326
|
+
_raise_for_status(response)
|
|
327
|
+
return SignResult.model_validate(response.json())
|
|
328
|
+
|
|
329
|
+
async def sign_batch(
|
|
330
|
+
self,
|
|
331
|
+
items: list[dict[str, Any]],
|
|
332
|
+
generator_id: str | None = None,
|
|
333
|
+
private_key: str | None = None,
|
|
334
|
+
) -> list[dict[str, Any]]:
|
|
335
|
+
"""Async variant of :meth:`CertivuClient.sign_batch`."""
|
|
336
|
+
gid = generator_id or self._generator_id
|
|
337
|
+
pk = private_key or self._private_key
|
|
338
|
+
if not gid:
|
|
339
|
+
raise ValueError("generator_id is required")
|
|
340
|
+
if not pk:
|
|
341
|
+
raise ValueError("private_key is required")
|
|
342
|
+
|
|
343
|
+
records = [
|
|
344
|
+
_build_sign_payload(item["content"], item["model"], gid, pk, item.get("watermark_id"))
|
|
345
|
+
for item in items
|
|
346
|
+
]
|
|
347
|
+
response = await self._client.post("/v1/records/batch", json={"records": records})
|
|
348
|
+
_raise_for_status(response)
|
|
349
|
+
return response.json().get("results", [])
|
|
350
|
+
|
|
351
|
+
async def verify(self, content: bytes, token: str | None = None) -> VerifyResult:
|
|
352
|
+
"""Async variant of :meth:`CertivuClient.verify`."""
|
|
353
|
+
files: dict[str, Any] = {"content": ("content", content, "application/octet-stream")}
|
|
354
|
+
data = {"token": token} if token else {}
|
|
355
|
+
response = await self._client.post("/v1/verify", files=files, data=data)
|
|
356
|
+
_raise_for_status(response)
|
|
357
|
+
return VerifyResult.model_validate(response.json())
|
|
358
|
+
|
|
359
|
+
async def verify_batch(self, items: list[dict[str, Any]]) -> list[VerifyResult]:
|
|
360
|
+
"""Async variant of :meth:`CertivuClient.verify_batch`."""
|
|
361
|
+
import base64
|
|
362
|
+
|
|
363
|
+
payload_items = []
|
|
364
|
+
for item in items:
|
|
365
|
+
entry: dict[str, Any] = {"content": base64.b64encode(item["content"]).decode("ascii")}
|
|
366
|
+
if item.get("token"):
|
|
367
|
+
entry["token"] = item["token"]
|
|
368
|
+
payload_items.append(entry)
|
|
369
|
+
|
|
370
|
+
response = await self._client.post("/v1/verify/batch", json={"items": payload_items})
|
|
371
|
+
_raise_for_status(response)
|
|
372
|
+
return [VerifyResult.model_validate(r) for r in response.json().get("results", [])]
|
|
373
|
+
|
|
374
|
+
async def get_token_status(self, token: str) -> TokenStatus:
|
|
375
|
+
"""Async variant of :meth:`CertivuClient.get_token_status`."""
|
|
376
|
+
response = await self._client.get(f"/v1/verify/status/{token}")
|
|
377
|
+
_raise_for_status(response)
|
|
378
|
+
return TokenStatus.model_validate(response.json())
|
|
379
|
+
|
|
380
|
+
async def get_audit_log(self, page: int = 1, limit: int = 20) -> AuditPage:
|
|
381
|
+
"""Async variant of :meth:`CertivuClient.get_audit_log`."""
|
|
382
|
+
response = await self._client.get("/v1/audit", params={"page": page, "limit": limit})
|
|
383
|
+
_raise_for_status(response)
|
|
384
|
+
return AuditPage.model_validate(response.json())
|
|
385
|
+
|
|
386
|
+
async def generate_keypair(self) -> GeneratorKeypair:
|
|
387
|
+
"""Async variant of :meth:`CertivuClient.generate_keypair`."""
|
|
388
|
+
response = await self._client.post("/v1/generators/keypair")
|
|
389
|
+
_raise_for_status(response)
|
|
390
|
+
return GeneratorKeypair.model_validate(response.json())
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: certivu
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Python SDK for Certivu — quantum-resistant trust infrastructure for AI-generated content
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Project-URL: Homepage, https://certivu.ai
|
|
7
|
+
Project-URL: Documentation, https://docs.certivu.ai
|
|
8
|
+
Project-URL: API Reference, https://api.certivu.ai/docs
|
|
9
|
+
Keywords: certivu,ai,provenance,ml-dsa,dilithium,post-quantum,verification,watermark
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Security :: Cryptography
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Requires-Dist: pydantic>=2.0
|
|
23
|
+
Provides-Extra: signing
|
|
24
|
+
Requires-Dist: dilithium-py>=0.0.6; extra == "signing"
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
28
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
29
|
+
Requires-Dist: dilithium-py>=0.0.6; extra == "dev"
|
|
30
|
+
|
|
31
|
+
# certivu-python
|
|
32
|
+
|
|
33
|
+
Python SDK for [Certivu](https://certivu.ai) — quantum-resistant trust infrastructure for AI-generated content.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install certivu # verify-only (httpx + pydantic)
|
|
39
|
+
pip install certivu[signing] # + ML-DSA signing (dilithium-py)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from certivu import CertivuClient
|
|
46
|
+
|
|
47
|
+
client = CertivuClient(
|
|
48
|
+
api_key="ctv_key_abc123",
|
|
49
|
+
generator_id="your-generator-uuid",
|
|
50
|
+
private_key="base64-ml-dsa-private-key", # requires certivu[signing]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Sign AI-generated content
|
|
54
|
+
result = client.sign(content=image_bytes, model="stable-diffusion-xl")
|
|
55
|
+
print(result.token) # ctv_7f3kx9mq2... — embed in XMP metadata
|
|
56
|
+
|
|
57
|
+
# Verify — no API key needed, always free
|
|
58
|
+
result = client.verify(content=image_bytes)
|
|
59
|
+
if result.authentic and result.confidence == "high":
|
|
60
|
+
print(result.provenance.org, result.provenance.signed_at)
|
|
61
|
+
|
|
62
|
+
# Verify without re-uploading the image
|
|
63
|
+
status = client.get_token_status("ctv_7f3kx9mq2...")
|
|
64
|
+
|
|
65
|
+
# Batch verify
|
|
66
|
+
results = client.verify_batch([
|
|
67
|
+
{"content": image1_bytes},
|
|
68
|
+
{"content": image2_bytes, "token": "ctv_..."},
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
# Audit log
|
|
72
|
+
page = client.get_audit_log(page=1, limit=50)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Async
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from certivu import AsyncCertivuClient
|
|
79
|
+
|
|
80
|
+
async with AsyncCertivuClient(api_key="ctv_key_abc123") as client:
|
|
81
|
+
result = await client.verify(content=image_bytes)
|
|
82
|
+
results = await client.verify_batch([{"content": img} for img in images])
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Signing setup
|
|
86
|
+
|
|
87
|
+
1. Generate a keypair via the dashboard or API:
|
|
88
|
+
```python
|
|
89
|
+
keypair = client.generate_keypair()
|
|
90
|
+
# Save keypair.private_key immediately — never stored by Certivu
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
2. Register the generator in your dashboard with the public key.
|
|
94
|
+
|
|
95
|
+
3. Sign content:
|
|
96
|
+
```python
|
|
97
|
+
result = client.sign(
|
|
98
|
+
content=image_bytes,
|
|
99
|
+
model="stable-diffusion-xl",
|
|
100
|
+
generator_id=keypair_id,
|
|
101
|
+
private_key=keypair.private_key,
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Confidence levels
|
|
106
|
+
|
|
107
|
+
| Confidence | Meaning |
|
|
108
|
+
|------------|---------|
|
|
109
|
+
| `high` | Watermark ✓ + Record ✓ + Signature ✓ — full chain intact |
|
|
110
|
+
| `medium` | Record ✓ + Signature ✓ — re-uploaded without watermark |
|
|
111
|
+
| `low` | Partial signals — something is off |
|
|
112
|
+
| `none` | Not signed by Certivu — no claim either way |
|
|
113
|
+
|
|
114
|
+
## Links
|
|
115
|
+
|
|
116
|
+
- [API Reference](https://api.certivu.ai/docs)
|
|
117
|
+
- [Documentation](https://docs.certivu.ai)
|
|
118
|
+
- [Pricing](https://certivu.ai/pricing)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
certivu/__init__.py
|
|
4
|
+
certivu/_crypto.py
|
|
5
|
+
certivu/_exceptions.py
|
|
6
|
+
certivu/_models.py
|
|
7
|
+
certivu/client.py
|
|
8
|
+
certivu.egg-info/PKG-INFO
|
|
9
|
+
certivu.egg-info/SOURCES.txt
|
|
10
|
+
certivu.egg-info/dependency_links.txt
|
|
11
|
+
certivu.egg-info/requires.txt
|
|
12
|
+
certivu.egg-info/top_level.txt
|
|
13
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
certivu
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "certivu"
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "Python SDK for Certivu — quantum-resistant trust infrastructure for AI-generated content"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
keywords = [
|
|
13
|
+
"certivu",
|
|
14
|
+
"ai",
|
|
15
|
+
"provenance",
|
|
16
|
+
"ml-dsa",
|
|
17
|
+
"dilithium",
|
|
18
|
+
"post-quantum",
|
|
19
|
+
"verification",
|
|
20
|
+
"watermark",
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 5 - Production/Stable",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.9",
|
|
27
|
+
"Programming Language :: Python :: 3.10",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"Topic :: Security :: Cryptography",
|
|
31
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
32
|
+
]
|
|
33
|
+
dependencies = ["httpx>=0.27", "pydantic>=2.0"]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
signing = ["dilithium-py>=0.0.6"]
|
|
37
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.21", "dilithium-py>=0.0.6"]
|
|
38
|
+
|
|
39
|
+
[project.urls]
|
|
40
|
+
Homepage = "https://certivu.ai"
|
|
41
|
+
Documentation = "https://docs.certivu.ai"
|
|
42
|
+
"API Reference" = "https://api.certivu.ai/docs"
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
asyncio_mode = "auto"
|
certivu-1.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Unit tests for CertivuClient and AsyncCertivuClient using respx to mock HTTP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import unittest.mock as mock
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import pytest
|
|
11
|
+
import respx
|
|
12
|
+
|
|
13
|
+
from certivu import (
|
|
14
|
+
AsyncCertivuClient,
|
|
15
|
+
AuthError,
|
|
16
|
+
CertivuClient,
|
|
17
|
+
QuotaError,
|
|
18
|
+
SignResult,
|
|
19
|
+
VerifyResult,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
BASE_URL = "https://api.certivu.ai"
|
|
23
|
+
API_KEY = "ctv_key_test"
|
|
24
|
+
GENERATOR_ID = "gen-00000000-0000-0000-0000-000000000001"
|
|
25
|
+
PRIVATE_KEY = "dGVzdC1wcml2YXRlLWtleQ==" # dummy — sign() is mocked in these tests
|
|
26
|
+
|
|
27
|
+
VERIFY_OK = {
|
|
28
|
+
"authentic": True,
|
|
29
|
+
"tampered": False,
|
|
30
|
+
"confidence": "high",
|
|
31
|
+
"token_source": "provided",
|
|
32
|
+
"signals": {"watermark_found": True, "record_found": True, "signature_valid": True},
|
|
33
|
+
"provenance": {"org": "Acme AI", "model": "stable-diffusion-xl", "signed_at": "2026-06-07T00:00:00Z"},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
SIGN_OK = {"record_id": "rec-abc123", "token": "ctv_test_token", "deduplicated": False}
|
|
37
|
+
|
|
38
|
+
AUDIT_OK = {
|
|
39
|
+
"events": [{"event_id": "evt-1", "org_id": "org-1", "type": "sign", "timestamp": "2026-06-07T00:00:00Z", "metadata": {}}],
|
|
40
|
+
"total": 1,
|
|
41
|
+
"page": 1,
|
|
42
|
+
"limit": 20,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
STATUS_OK = {
|
|
46
|
+
"record_id": "rec-abc123",
|
|
47
|
+
"org_id": "org-1",
|
|
48
|
+
"model": "stable-diffusion-xl",
|
|
49
|
+
"signed_at": "2026-06-07T00:00:00Z",
|
|
50
|
+
"generator_status": "active",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── Sync client ──────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@respx.mock(base_url=BASE_URL)
|
|
58
|
+
def test_verify_returns_result(respx_mock: respx.MockRouter) -> None:
|
|
59
|
+
respx_mock.post("/v1/verify").mock(return_value=httpx.Response(200, json=VERIFY_OK))
|
|
60
|
+
|
|
61
|
+
client = CertivuClient(api_key=API_KEY)
|
|
62
|
+
result = client.verify(content=b"fake-image-bytes")
|
|
63
|
+
|
|
64
|
+
assert result.authentic is True
|
|
65
|
+
assert result.confidence == "high"
|
|
66
|
+
assert result.provenance is not None
|
|
67
|
+
assert result.provenance.org == "Acme AI"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@respx.mock(base_url=BASE_URL)
|
|
71
|
+
def test_verify_with_token(respx_mock: respx.MockRouter) -> None:
|
|
72
|
+
respx_mock.post("/v1/verify").mock(return_value=httpx.Response(200, json=VERIFY_OK))
|
|
73
|
+
|
|
74
|
+
client = CertivuClient(api_key=API_KEY)
|
|
75
|
+
result = client.verify(content=b"fake-image", token="ctv_sometoken")
|
|
76
|
+
|
|
77
|
+
assert isinstance(result, VerifyResult)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@respx.mock(base_url=BASE_URL)
|
|
81
|
+
def test_verify_batch(respx_mock: respx.MockRouter) -> None:
|
|
82
|
+
respx_mock.post("/v1/verify/batch").mock(
|
|
83
|
+
return_value=httpx.Response(200, json={"results": [VERIFY_OK, VERIFY_OK]})
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
client = CertivuClient(api_key=API_KEY)
|
|
87
|
+
results = client.verify_batch([{"content": b"img1"}, {"content": b"img2", "token": "ctv_tok"}])
|
|
88
|
+
|
|
89
|
+
assert len(results) == 2
|
|
90
|
+
assert all(r.authentic for r in results)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@respx.mock(base_url=BASE_URL)
|
|
94
|
+
def test_get_audit_log(respx_mock: respx.MockRouter) -> None:
|
|
95
|
+
respx_mock.get("/v1/audit").mock(return_value=httpx.Response(200, json=AUDIT_OK))
|
|
96
|
+
|
|
97
|
+
client = CertivuClient(api_key=API_KEY)
|
|
98
|
+
page = client.get_audit_log(page=1, limit=20)
|
|
99
|
+
|
|
100
|
+
assert page.total == 1
|
|
101
|
+
assert page.events[0].type == "sign"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@respx.mock(base_url=BASE_URL)
|
|
105
|
+
def test_get_token_status(respx_mock: respx.MockRouter) -> None:
|
|
106
|
+
respx_mock.get("/v1/verify/status/ctv_test_token").mock(
|
|
107
|
+
return_value=httpx.Response(200, json=STATUS_OK)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
client = CertivuClient(api_key=API_KEY)
|
|
111
|
+
status = client.get_token_status("ctv_test_token")
|
|
112
|
+
|
|
113
|
+
assert status.record_id == "rec-abc123"
|
|
114
|
+
assert status.generator_status == "active"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@respx.mock(base_url=BASE_URL)
|
|
118
|
+
def test_sign_calls_api(respx_mock: respx.MockRouter) -> None:
|
|
119
|
+
respx_mock.post("/v1/records").mock(return_value=httpx.Response(201, json=SIGN_OK))
|
|
120
|
+
|
|
121
|
+
client = CertivuClient(api_key=API_KEY, generator_id=GENERATOR_ID, private_key=PRIVATE_KEY)
|
|
122
|
+
|
|
123
|
+
with mock.patch("certivu.client.ml_dsa_sign", return_value="mock-sig"):
|
|
124
|
+
result = client.sign(content=b"fake-image", model="stable-diffusion-xl")
|
|
125
|
+
|
|
126
|
+
assert isinstance(result, SignResult)
|
|
127
|
+
assert result.token == "ctv_test_token"
|
|
128
|
+
assert result.record_id == "rec-abc123"
|
|
129
|
+
|
|
130
|
+
# verify request body structure
|
|
131
|
+
sent_body = json.loads(respx_mock.calls.last.request.content)
|
|
132
|
+
assert sent_body["generator_id"] == GENERATOR_ID
|
|
133
|
+
assert sent_body["model"] == "stable-diffusion-xl"
|
|
134
|
+
assert sent_body["content_hash"].startswith("sha3-256:")
|
|
135
|
+
assert sent_body["signature"] == "mock-sig"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_sign_raises_without_generator_id() -> None:
|
|
139
|
+
client = CertivuClient(api_key=API_KEY, private_key=PRIVATE_KEY)
|
|
140
|
+
with pytest.raises(ValueError, match="generator_id"):
|
|
141
|
+
with mock.patch("certivu.client.ml_dsa_sign", return_value="mock-sig"):
|
|
142
|
+
client.sign(content=b"img", model="model-x")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@respx.mock(base_url=BASE_URL)
|
|
146
|
+
def test_auth_error_on_401(respx_mock: respx.MockRouter) -> None:
|
|
147
|
+
respx_mock.post("/v1/verify").mock(
|
|
148
|
+
return_value=httpx.Response(401, json={"error": "unauthorized"})
|
|
149
|
+
)
|
|
150
|
+
client = CertivuClient(api_key="bad_key")
|
|
151
|
+
with pytest.raises(AuthError):
|
|
152
|
+
client.verify(content=b"img")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@respx.mock(base_url=BASE_URL)
|
|
156
|
+
def test_quota_error_on_402(respx_mock: respx.MockRouter) -> None:
|
|
157
|
+
respx_mock.post("/v1/records").mock(
|
|
158
|
+
return_value=httpx.Response(
|
|
159
|
+
402,
|
|
160
|
+
json={
|
|
161
|
+
"error": "signature_limit_reached",
|
|
162
|
+
"upgrade_url": "https://certivu.ai/pricing",
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
client = CertivuClient(api_key=API_KEY, generator_id=GENERATOR_ID, private_key=PRIVATE_KEY)
|
|
167
|
+
with pytest.raises(QuotaError) as exc_info:
|
|
168
|
+
with mock.patch("certivu.client.ml_dsa_sign", return_value="mock-sig"):
|
|
169
|
+
client.sign(content=b"img", model="m")
|
|
170
|
+
assert exc_info.value.upgrade_url == "https://certivu.ai/pricing"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_context_manager_closes_client() -> None:
|
|
174
|
+
with CertivuClient(api_key=API_KEY) as client:
|
|
175
|
+
assert not client._client.is_closed
|
|
176
|
+
assert client._client.is_closed
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ── Async client ─────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@pytest.mark.asyncio
|
|
183
|
+
@respx.mock(base_url=BASE_URL)
|
|
184
|
+
async def test_async_verify(respx_mock: respx.MockRouter) -> None:
|
|
185
|
+
respx_mock.post("/v1/verify").mock(return_value=httpx.Response(200, json=VERIFY_OK))
|
|
186
|
+
|
|
187
|
+
async with AsyncCertivuClient(api_key=API_KEY) as client:
|
|
188
|
+
result = await client.verify(content=b"fake-image")
|
|
189
|
+
|
|
190
|
+
assert result.authentic is True
|
|
191
|
+
assert result.confidence == "high"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@pytest.mark.asyncio
|
|
195
|
+
@respx.mock(base_url=BASE_URL)
|
|
196
|
+
async def test_async_sign(respx_mock: respx.MockRouter) -> None:
|
|
197
|
+
respx_mock.post("/v1/records").mock(return_value=httpx.Response(201, json=SIGN_OK))
|
|
198
|
+
|
|
199
|
+
async with AsyncCertivuClient(
|
|
200
|
+
api_key=API_KEY, generator_id=GENERATOR_ID, private_key=PRIVATE_KEY
|
|
201
|
+
) as client:
|
|
202
|
+
with mock.patch("certivu.client.ml_dsa_sign", return_value="mock-sig"):
|
|
203
|
+
result = await client.sign(content=b"img", model="sdxl")
|
|
204
|
+
|
|
205
|
+
assert result.token == "ctv_test_token"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@pytest.mark.asyncio
|
|
209
|
+
@respx.mock(base_url=BASE_URL)
|
|
210
|
+
async def test_async_verify_batch(respx_mock: respx.MockRouter) -> None:
|
|
211
|
+
respx_mock.post("/v1/verify/batch").mock(
|
|
212
|
+
return_value=httpx.Response(200, json={"results": [VERIFY_OK]})
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
async with AsyncCertivuClient(api_key=API_KEY) as client:
|
|
216
|
+
results = await client.verify_batch([{"content": b"img"}])
|
|
217
|
+
|
|
218
|
+
assert len(results) == 1
|
|
219
|
+
assert results[0].authentic is True
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ── Crypto helpers ────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_sha3_hash_format() -> None:
|
|
226
|
+
from certivu._crypto import sha3_256_hash
|
|
227
|
+
|
|
228
|
+
result = sha3_256_hash(b"hello")
|
|
229
|
+
assert result.startswith("sha3-256:")
|
|
230
|
+
assert len(result) == len("sha3-256:") + 64
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_canonical_json_sorts_keys() -> None:
|
|
234
|
+
from certivu._crypto import canonical_json
|
|
235
|
+
|
|
236
|
+
obj = {"z": 1, "a": 2, "m": 3}
|
|
237
|
+
result = canonical_json(obj)
|
|
238
|
+
assert result == '{"a":2,"m":3,"z":1}'
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_sha3_accepts_str() -> None:
|
|
242
|
+
from certivu._crypto import sha3_256_hash
|
|
243
|
+
|
|
244
|
+
assert sha3_256_hash("hello") == sha3_256_hash(b"hello")
|