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 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)
@@ -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,11 @@
1
+ httpx>=0.27
2
+ pydantic>=2.0
3
+
4
+ [dev]
5
+ pytest>=8.0
6
+ pytest-asyncio>=0.23
7
+ respx>=0.21
8
+ dilithium-py>=0.0.6
9
+
10
+ [signing]
11
+ dilithium-py>=0.0.6
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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")