jetv-clearing-sdk 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ """clearing-sdk-py — official Python SDK for the Clearing EPID service.
2
+
3
+ Three capability tiers with a uniform shape across the Go / Python / TS SDKs:
4
+
5
+ L1 ResolveClient — read: resolve / get_by_epid / kinds (cache + breaker)
6
+ L2 SourceClient — register: ensure / link / affiliate (F4 RS256 auto-sign)
7
+ L3 UnifyClient — unify: prove_key / submit_verified_attr / bind / link_realm
8
+
9
+ Tier == permission boundary: building L2/L3 requires a source RSA key, so a
10
+ read-only consumer cannot obtain the write/unify handles.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .canonical import FloatInSignedBodyError, canonical_bytes
16
+ from .client import CONTRACT_VERSION, ClearingClient
17
+ from .errors import (
18
+ APIError,
19
+ ClearingError,
20
+ Conflict,
21
+ InvalidRequest,
22
+ NotRegistered,
23
+ PermissionDenied,
24
+ RateLimited,
25
+ Unavailable,
26
+ )
27
+ from .signing import RSASigner, load_rsa_private_key
28
+ from .types import (
29
+ RELATION_ACCOUNTABLE_FOR,
30
+ RELATION_MEMBER_OF,
31
+ AttrAssertion,
32
+ DedupResult,
33
+ Ensured,
34
+ Identity,
35
+ Resolved,
36
+ )
37
+
38
+ __all__ = [
39
+ "ClearingClient",
40
+ "CONTRACT_VERSION",
41
+ "RSASigner",
42
+ "load_rsa_private_key",
43
+ "canonical_bytes",
44
+ "FloatInSignedBodyError",
45
+ "Identity",
46
+ "Resolved",
47
+ "Ensured",
48
+ "AttrAssertion",
49
+ "DedupResult",
50
+ "RELATION_MEMBER_OF",
51
+ "RELATION_ACCOUNTABLE_FOR",
52
+ "ClearingError",
53
+ "NotRegistered",
54
+ "Unavailable",
55
+ "PermissionDenied",
56
+ "Conflict",
57
+ "InvalidRequest",
58
+ "RateLimited",
59
+ "APIError",
60
+ ]
@@ -0,0 +1,83 @@
1
+ """Minimal resilience primitives (TTL cache + circuit breaker), mirroring the Go
2
+ epidclient defaults. Kept dependency-free (no cachetools) for a small footprint."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import threading
7
+ import time
8
+ from dataclasses import dataclass
9
+ from typing import Callable, Generic, Optional, TypeVar
10
+
11
+ __all__ = ["TTLCache", "Breaker", "CacheEntry"]
12
+
13
+ T = TypeVar("T")
14
+
15
+
16
+ @dataclass
17
+ class CacheEntry(Generic[T]):
18
+ value: T
19
+ registered: bool
20
+ expires_at: float
21
+
22
+
23
+ class TTLCache(Generic[T]):
24
+ """Thread-safe TTL cache with positive/negative entries."""
25
+
26
+ def __init__(self, now: Callable[[], float] = time.monotonic):
27
+ self._now = now
28
+ self._lock = threading.Lock()
29
+ self._data: dict[str, CacheEntry[T]] = {}
30
+
31
+ def get(self, key: str) -> Optional[CacheEntry[T]]:
32
+ with self._lock:
33
+ e = self._data.get(key)
34
+ if e is None:
35
+ return None
36
+ if self._now() > e.expires_at:
37
+ del self._data[key]
38
+ return None
39
+ return e
40
+
41
+ def put(self, key: str, value: T, registered: bool, ttl: float) -> None:
42
+ with self._lock:
43
+ self._data[key] = CacheEntry(value, registered, self._now() + ttl)
44
+
45
+ def invalidate(self, key: str) -> None:
46
+ with self._lock:
47
+ self._data.pop(key, None)
48
+
49
+
50
+ class Breaker:
51
+ """Consecutive-failure circuit breaker (mirrors epidclient.breaker)."""
52
+
53
+ def __init__(self, threshold: int, open_timeout: float, now: Callable[[], float] = time.monotonic):
54
+ self._threshold = threshold
55
+ self._open_timeout = open_timeout
56
+ self._now = now
57
+ self._lock = threading.Lock()
58
+ self._failures = 0
59
+ self._opened_at = 0.0
60
+ self._open = False
61
+
62
+ def allow(self) -> bool:
63
+ with self._lock:
64
+ if not self._open:
65
+ return True
66
+ # half-open after the open window elapses.
67
+ if self._now() - self._opened_at >= self._open_timeout:
68
+ self._open = False
69
+ self._failures = 0
70
+ return True
71
+ return False
72
+
73
+ def success(self) -> None:
74
+ with self._lock:
75
+ self._failures = 0
76
+ self._open = False
77
+
78
+ def failure(self) -> None:
79
+ with self._lock:
80
+ self._failures += 1
81
+ if self._failures >= self._threshold:
82
+ self._open = True
83
+ self._opened_at = self._now()
@@ -0,0 +1,149 @@
1
+ """Go-exact canonical JSON for cross-language F4 signing.
2
+
3
+ The Clearing server canonicalizes with Go ``encoding/json`` (pkg/sourcesign):
4
+ keys sorted, no insignificant whitespace, number literals preserved, and — the
5
+ subtle part — Go's HTML-safe string escaping (``<`` ``>`` ``&`` -> ``\\u003c``
6
+ ``\\u003e`` ``\\u0026``) plus ``\\u2028``/``\\u2029``. Python's ``json.dumps``
7
+ does NOT escape those, so a naive implementation would byte-diverge and produce
8
+ signatures the server rejects. This module reproduces Go byte-for-byte; the
9
+ ``html_escape`` golden vector guards it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from typing import Any
16
+
17
+ __all__ = ["canonical_bytes", "FloatInSignedBodyError", "reject_float"]
18
+
19
+
20
+ class FloatInSignedBodyError(ValueError):
21
+ """Raised when a signed body contains a JSON float (precision drift guard)."""
22
+
23
+
24
+ class _Num(str):
25
+ """A JSON number token, kept verbatim (mirrors Go json.Number).
26
+
27
+ Subclasses str so we can distinguish a JSON number from a JSON string after
28
+ parsing (both would otherwise collapse to str under parse_int/parse_float).
29
+ """
30
+
31
+ __slots__ = ()
32
+
33
+ @property
34
+ def is_float(self) -> bool:
35
+ return "." in self or "e" in self or "E" in self
36
+
37
+
38
+ # Short escapes match Go's encodeState.string exactly (no \b/\f shorthand: Go
39
+ # emits \u0008 / \u000c for those).
40
+ _SHORT = {
41
+ '"': '\\"',
42
+ "\\": "\\\\",
43
+ "\n": "\\n",
44
+ "\r": "\\r",
45
+ "\t": "\\t",
46
+ }
47
+
48
+
49
+ def _escape_string(s: str) -> str:
50
+ out = ['"']
51
+ for ch in s:
52
+ o = ord(ch)
53
+ short = _SHORT.get(ch)
54
+ if short is not None:
55
+ out.append(short)
56
+ elif ch in "<>&" or o < 0x20:
57
+ out.append("\\u%04x" % o)
58
+ elif o == 0x2028 or o == 0x2029:
59
+ out.append("\\u%04x" % o)
60
+ else:
61
+ out.append(ch)
62
+ out.append('"')
63
+ return "".join(out)
64
+
65
+
66
+ def _write(v: Any, out: list[str]) -> None:
67
+ if isinstance(v, dict):
68
+ out.append("{")
69
+ first = True
70
+ for k in sorted(v.keys()): # code-point order == Go UTF-8 byte order
71
+ if not first:
72
+ out.append(",")
73
+ first = False
74
+ out.append(_escape_string(k))
75
+ out.append(":")
76
+ _write(v[k], out)
77
+ out.append("}")
78
+ elif isinstance(v, list):
79
+ out.append("[")
80
+ for i, e in enumerate(v):
81
+ if i:
82
+ out.append(",")
83
+ _write(e, out)
84
+ out.append("]")
85
+ elif isinstance(v, _Num):
86
+ out.append(str(v))
87
+ elif v is True:
88
+ out.append("true")
89
+ elif v is False:
90
+ out.append("false")
91
+ elif v is None:
92
+ out.append("null")
93
+ elif isinstance(v, str):
94
+ out.append(_escape_string(v))
95
+ elif isinstance(v, bool): # unreachable (handled above) but defensive
96
+ out.append("true" if v else "false")
97
+ elif isinstance(v, int):
98
+ out.append(str(v))
99
+ else:
100
+ raise TypeError(f"canonical: unsupported type {type(v)!r}")
101
+
102
+
103
+ def _parse(body: bytes | str) -> Any:
104
+ if isinstance(body, bytes):
105
+ body = body.decode("utf-8")
106
+ # parse_int/parse_float keep the verbatim numeric token (Go json.Number).
107
+ return json.loads(body, parse_int=_Num, parse_float=_Num)
108
+
109
+
110
+ def canonical_bytes(body: bytes | str | dict | list) -> bytes:
111
+ """Return the deterministic canonical byte sequence for a JSON value.
112
+
113
+ Accepts raw JSON (bytes/str) or an already-decoded dict/list. Raw input is
114
+ preferred for signing so number literals are preserved exactly.
115
+ """
116
+ if isinstance(body, (bytes, str)):
117
+ v = _parse(body)
118
+ else:
119
+ v = body
120
+ out: list[str] = []
121
+ _write(v, out)
122
+ return "".join(out).encode("utf-8")
123
+
124
+
125
+ def reject_float(body: bytes | str | dict | list) -> None:
126
+ """Raise FloatInSignedBodyError if the value contains any JSON float.
127
+
128
+ Signed bodies must not contain floats (cross-language precision drift): use
129
+ integer minor units or strings.
130
+ """
131
+ if isinstance(body, (bytes, str)):
132
+ v = _parse(body)
133
+ else:
134
+ v = body
135
+ _walk_reject(v)
136
+
137
+
138
+ def _walk_reject(v: Any) -> None:
139
+ if isinstance(v, dict):
140
+ for e in v.values():
141
+ _walk_reject(e)
142
+ elif isinstance(v, list):
143
+ for e in v:
144
+ _walk_reject(e)
145
+ elif isinstance(v, _Num):
146
+ if v.is_float:
147
+ raise FloatInSignedBodyError(str(v))
148
+ elif isinstance(v, float):
149
+ raise FloatInSignedBodyError(repr(v))
clearing_sdk/client.py ADDED
@@ -0,0 +1,79 @@
1
+ """ClearingClient facade. Capability == construction credential: read-only
2
+ callers cannot obtain L2/L3 (no source key), mirroring the Go SDK tiers."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import time
7
+ from typing import Callable, Optional
8
+
9
+ import httpx
10
+
11
+ from .resolve import ResolveClient
12
+ from .signing import RSASigner
13
+ from .source import SourceClient
14
+ from .transport import Transport
15
+ from .unify import UnifyClient
16
+
17
+ __all__ = ["ClearingClient", "CONTRACT_VERSION"]
18
+
19
+ # OpenAPI contract version this SDK targets (compared against live info.version).
20
+ CONTRACT_VERSION = "1.0.0"
21
+
22
+
23
+ class ClearingClient:
24
+ def __init__(
25
+ self,
26
+ base_url: str,
27
+ *,
28
+ signer: Optional[RSASigner] = None,
29
+ with_unify: bool = False,
30
+ http_client: Optional[httpx.AsyncClient] = None,
31
+ write_timeout: float = 5.0,
32
+ ttl: float = 300.0,
33
+ negative_ttl: float = 30.0,
34
+ failure_threshold: int = 5,
35
+ open_timeout: float = 10.0,
36
+ now: Callable[[], float] = time.time,
37
+ mono: Callable[[], float] = time.monotonic,
38
+ ):
39
+ self._owns_client = http_client is None
40
+ self._client = http_client or httpx.AsyncClient(timeout=write_timeout)
41
+ tr = Transport(base_url, self._client, now=now)
42
+ self.l1 = ResolveClient(
43
+ tr,
44
+ ttl=ttl,
45
+ negative_ttl=negative_ttl,
46
+ failure_threshold=failure_threshold,
47
+ open_timeout=open_timeout,
48
+ now=mono,
49
+ )
50
+ self.l2: Optional[SourceClient] = SourceClient(tr, signer) if signer is not None else None
51
+ self.l3: Optional[UnifyClient] = (
52
+ UnifyClient(tr, signer) if (signer is not None and with_unify) else None
53
+ )
54
+
55
+ @classmethod
56
+ def read_only(cls, base_url: str, **kw) -> "ClearingClient":
57
+ return cls(base_url, **kw)
58
+
59
+ @classmethod
60
+ def source(cls, base_url: str, signer: RSASigner, **kw) -> "ClearingClient":
61
+ return cls(base_url, signer=signer, **kw)
62
+
63
+ @classmethod
64
+ def unify(cls, base_url: str, signer: RSASigner, **kw) -> "ClearingClient":
65
+ return cls(base_url, signer=signer, with_unify=True, **kw)
66
+
67
+ @property
68
+ def version(self) -> str:
69
+ return CONTRACT_VERSION
70
+
71
+ async def aclose(self) -> None:
72
+ if self._owns_client:
73
+ await self._client.aclose()
74
+
75
+ async def __aenter__(self) -> "ClearingClient":
76
+ return self
77
+
78
+ async def __aexit__(self, *exc) -> None:
79
+ await self.aclose()
clearing_sdk/errors.py ADDED
@@ -0,0 +1,105 @@
1
+ """SDK error taxonomy (mirrors the Go SDK sentinels).
2
+
3
+ Consumers branch on these to choose fail-open/closed; the SDK never silently
4
+ fabricates a fallback.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__ = [
10
+ "ClearingError",
11
+ "NotRegistered",
12
+ "Unavailable",
13
+ "PermissionDenied",
14
+ "Conflict",
15
+ "InvalidRequest",
16
+ "RateLimited",
17
+ "APIError",
18
+ "classify_status",
19
+ ]
20
+
21
+
22
+ class ClearingError(Exception):
23
+ """Base class for all SDK errors."""
24
+
25
+
26
+ class NotRegistered(ClearingError):
27
+ """Principal/identity authoritatively absent (404, safe to negative-cache)."""
28
+
29
+
30
+ class Unavailable(ClearingError):
31
+ """Clearing unreachable / circuit open / 5xx — not silently masked."""
32
+
33
+
34
+ class PermissionDenied(ClearingError):
35
+ """Source unauthorized or admin/governance gate rejected (401/403)."""
36
+
37
+
38
+ class Conflict(ClearingError):
39
+ """Unification/registration conflict (409)."""
40
+
41
+
42
+ class InvalidRequest(ClearingError):
43
+ """Semantically invalid request (400/422)."""
44
+
45
+
46
+ class RateLimited(ClearingError):
47
+ """Too many challenges/requests (429)."""
48
+
49
+
50
+ class APIError(ClearingError):
51
+ """Carries the server's stable code + HTTP status. Subclass of the classified
52
+ error so ``except PermissionDenied`` and precise logging both work."""
53
+
54
+ def __init__(self, status: int, code: str, detail: str = ""):
55
+ self.status = status
56
+ self.code = code
57
+ self.detail = detail
58
+ msg = f"clearing: {code} (http {status})"
59
+ if detail:
60
+ msg += f": {detail}"
61
+ super().__init__(msg)
62
+
63
+
64
+ # Concrete API error classes carrying the envelope, one per sentinel.
65
+ class _APINotRegistered(APIError, NotRegistered):
66
+ pass
67
+
68
+
69
+ class _APIPermission(APIError, PermissionDenied):
70
+ pass
71
+
72
+
73
+ class _APIConflict(APIError, Conflict):
74
+ pass
75
+
76
+
77
+ class _APIInvalid(APIError, InvalidRequest):
78
+ pass
79
+
80
+
81
+ class _APIRateLimited(APIError, RateLimited):
82
+ pass
83
+
84
+
85
+ class _APIUnavailable(APIError, Unavailable):
86
+ pass
87
+
88
+
89
+ def classify_status(status: int, code: str, detail: str = "") -> APIError | None:
90
+ """Map an HTTP status + envelope code to a classified APIError (or None on
91
+ 200). Single source of status->sentinel truth, aligned with the server's
92
+ writeRegistryError / writeUnifyError mappings."""
93
+ if status == 200:
94
+ return None
95
+ if status == 404:
96
+ return _APINotRegistered(status, code, detail)
97
+ if status in (401, 403):
98
+ return _APIPermission(status, code, detail)
99
+ if status in (409, 428):
100
+ return _APIConflict(status, code, detail)
101
+ if status in (400, 422):
102
+ return _APIInvalid(status, code, detail)
103
+ if status == 429:
104
+ return _APIRateLimited(status, code, detail)
105
+ return _APIUnavailable(status, code, detail)
clearing_sdk/kinds.py ADDED
@@ -0,0 +1,63 @@
1
+ """external->canonical kind mapping cache, with a compiled-in degraded fallback.
2
+
3
+ The server is authoritative; the fallback is a last resort with a TTL and a
4
+ ``degraded`` flag (never silently authoritative)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import time
10
+ from typing import Callable
11
+
12
+ from .errors import ClearingError
13
+ from .transport import Transport
14
+
15
+ __all__ = ["KindsCache", "COMPILED_MAPPING"]
16
+
17
+ # Compiled-in fallback (mirrors clearing/pkg/canonicalkind.externalToCanonical
18
+ # exactly — 5 entries, no invented keys). Used only when the server is
19
+ # unreachable; flagged degraded.
20
+ COMPILED_MAPPING: dict[str, str] = {
21
+ "user": "human",
22
+ "client": "service",
23
+ "agent": "agent",
24
+ "realm": "org",
25
+ "provider": "provider",
26
+ }
27
+
28
+
29
+ class KindsCache:
30
+ def __init__(self, transport: Transport, *, ttl: float = 300.0, now: Callable[[], float] = time.monotonic):
31
+ self._tr = transport
32
+ self._ttl = ttl
33
+ self._now = now
34
+ self._lock = asyncio.Lock()
35
+ self._mapping: dict[str, str] | None = None
36
+ self._expires_at = 0.0
37
+ self._degraded = False
38
+
39
+ async def get(self) -> dict[str, str]:
40
+ if self._mapping is not None and self._now() < self._expires_at:
41
+ return dict(self._mapping)
42
+ async with self._lock:
43
+ if self._mapping is not None and self._now() < self._expires_at:
44
+ return dict(self._mapping)
45
+ try:
46
+ data = await self._tr.get_json("/v1/kinds")
47
+ mapping = data.get("mapping") or {}
48
+ if mapping:
49
+ self._store(dict(mapping), degraded=False)
50
+ return dict(mapping)
51
+ except ClearingError:
52
+ pass
53
+ self._store(dict(COMPILED_MAPPING), degraded=True)
54
+ return dict(COMPILED_MAPPING)
55
+
56
+ def _store(self, mapping: dict[str, str], *, degraded: bool) -> None:
57
+ self._mapping = mapping
58
+ self._degraded = degraded
59
+ self._expires_at = self._now() + self._ttl
60
+
61
+ @property
62
+ def degraded(self) -> bool:
63
+ return self._degraded
clearing_sdk/py.typed ADDED
File without changes
@@ -0,0 +1,105 @@
1
+ """L1 read tier: resolve / get_by_epid / kinds, with TTL cache + single-flight +
2
+ circuit breaker mirroring the Go epidclient resilience contract."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import time
8
+ from typing import Callable
9
+
10
+ from ._resilience import Breaker, TTLCache
11
+ from .errors import APIError, NotRegistered, Unavailable
12
+ from .kinds import KindsCache
13
+ from .transport import Transport
14
+ from .types import Identity, Resolved
15
+
16
+ __all__ = ["ResolveClient"]
17
+
18
+
19
+ class ResolveClient:
20
+ def __init__(
21
+ self,
22
+ transport: Transport,
23
+ *,
24
+ ttl: float = 300.0,
25
+ negative_ttl: float = 30.0,
26
+ failure_threshold: int = 5,
27
+ open_timeout: float = 10.0,
28
+ now: Callable[[], float] = time.monotonic,
29
+ ):
30
+ self._tr = transport
31
+ self._ttl = ttl
32
+ self._neg_ttl = negative_ttl
33
+ self._cache: TTLCache[Resolved] = TTLCache(now=now)
34
+ self._breaker = Breaker(failure_threshold, open_timeout, now=now)
35
+ self._kinds = KindsCache(transport, ttl=ttl, now=now)
36
+ self._locks: dict[str, asyncio.Lock] = {}
37
+ self._locks_guard = asyncio.Lock()
38
+
39
+ async def resolve(self, ident: Identity) -> Resolved:
40
+ key = ident.cache_key()
41
+ e = self._cache.get(key)
42
+ if e is not None:
43
+ if e.registered:
44
+ return e.value
45
+ raise NotRegistered(ident.cache_key())
46
+ if not self._breaker.allow():
47
+ raise Unavailable() # circuit open — fail fast, no silent fallback
48
+
49
+ lock = await self._lock_for(key)
50
+ async with lock: # single-flight per key
51
+ e = self._cache.get(key)
52
+ if e is not None:
53
+ if e.registered:
54
+ return e.value
55
+ raise NotRegistered(ident.cache_key())
56
+ return await self._fetch(ident, key)
57
+
58
+ async def _fetch(self, ident: Identity, key: str) -> Resolved:
59
+ body = {
60
+ "auth_instance_id": ident.auth_instance_id,
61
+ "kind": ident.kind,
62
+ "principal_key": ident.key,
63
+ }
64
+ try:
65
+ data = await self._tr.post_json("/v1/principals/resolve", body)
66
+ except NotRegistered:
67
+ self._cache.put(key, Resolved("", "", ""), False, self._neg_ttl)
68
+ self._breaker.success() # authoritative 404 is not a backend fault
69
+ raise
70
+ except APIError as e:
71
+ if e.status == 404:
72
+ self._cache.put(key, Resolved("", "", ""), False, self._neg_ttl)
73
+ self._breaker.success()
74
+ raise NotRegistered(ident.cache_key()) from e
75
+ self._breaker.failure()
76
+ raise Unavailable() from e
77
+ except Unavailable:
78
+ self._breaker.failure()
79
+ raise
80
+ res = Resolved(data.get("epid", ""), data.get("canonical_kind", ""), data.get("status", ""))
81
+ self._cache.put(key, res, True, self._ttl)
82
+ self._breaker.success()
83
+ return res
84
+
85
+ async def get_by_epid(self, epid: str) -> Resolved:
86
+ data = await self._tr.get_json(f"/v1/principals/{epid}")
87
+ return Resolved(data.get("epid", ""), data.get("canonical_kind", ""), data.get("status", ""))
88
+
89
+ async def kinds(self) -> dict[str, str]:
90
+ return await self._kinds.get()
91
+
92
+ @property
93
+ def kinds_degraded(self) -> bool:
94
+ return self._kinds.degraded
95
+
96
+ def invalidate(self, ident: Identity) -> None:
97
+ self._cache.invalidate(ident.cache_key())
98
+
99
+ async def _lock_for(self, key: str) -> asyncio.Lock:
100
+ async with self._locks_guard:
101
+ lock = self._locks.get(key)
102
+ if lock is None:
103
+ lock = asyncio.Lock()
104
+ self._locks[key] = lock
105
+ return lock
@@ -0,0 +1,72 @@
1
+ """F4 RS256 + Ed25519 signing primitives, byte-aligned with the Go server.
2
+
3
+ RS256 = RSA-PKCS1v15 + SHA-256 over the canonical body (canonical.py), base64
4
+ standard-encoded. Ed25519 over raw challenge bytes. key_id is the sha256
5
+ fingerprint of the raw public key (mirrors internal/unify.KeyFingerprint).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+
13
+ from cryptography.hazmat.primitives import hashes, serialization
14
+ from cryptography.hazmat.primitives.asymmetric import ed25519, padding, rsa
15
+
16
+ from .canonical import canonical_bytes, reject_float
17
+
18
+ __all__ = [
19
+ "RSASigner",
20
+ "load_rsa_private_key",
21
+ "ed25519_public_b64",
22
+ "ed25519_sign_b64",
23
+ "key_fingerprint",
24
+ ]
25
+
26
+
27
+ def load_rsa_private_key(pem: bytes | str) -> rsa.RSAPrivateKey:
28
+ if isinstance(pem, str):
29
+ pem = pem.encode("utf-8")
30
+ key = serialization.load_pem_private_key(pem, password=None)
31
+ if not isinstance(key, rsa.RSAPrivateKey):
32
+ raise TypeError("signing: not an RSA private key")
33
+ return key
34
+
35
+
36
+ class RSASigner:
37
+ """Binds a source identity to its RSA key for F4 write-auth."""
38
+
39
+ def __init__(self, source_id: str, priv: rsa.RSAPrivateKey):
40
+ self.source_id = source_id
41
+ self._priv = priv
42
+
43
+ def sign_b64(self, body: bytes | dict | list, *, forbid_float: bool = True) -> str:
44
+ """RS256 over canonical(body) -> base64(std). Rejects floats by default
45
+ (signed bodies must use integer minor units / strings)."""
46
+ if forbid_float:
47
+ reject_float(body)
48
+ canon = canonical_bytes(body)
49
+ sig = self._priv.sign(canon, padding.PKCS1v15(), hashes.SHA256())
50
+ return base64.standard_b64encode(sig).decode("ascii")
51
+
52
+ def rotate(self, priv: rsa.RSAPrivateKey) -> None:
53
+ self._priv = priv
54
+
55
+
56
+ def ed25519_public_b64(priv: ed25519.Ed25519PrivateKey) -> str:
57
+ raw = priv.public_key().public_bytes(
58
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw
59
+ )
60
+ return base64.standard_b64encode(raw).decode("ascii")
61
+
62
+
63
+ def ed25519_sign_b64(priv: ed25519.Ed25519PrivateKey, challenge: str) -> str:
64
+ sig = priv.sign(challenge.encode("utf-8"))
65
+ return base64.standard_b64encode(sig).decode("ascii")
66
+
67
+
68
+ def key_fingerprint(priv: ed25519.Ed25519PrivateKey) -> str:
69
+ raw = priv.public_key().public_bytes(
70
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw
71
+ )
72
+ return "sha256:" + hashlib.sha256(raw).hexdigest()
clearing_sdk/source.py ADDED
@@ -0,0 +1,46 @@
1
+ """L2 registration tier: ensure / link / affiliate, every write F4-signed."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .signing import RSASigner
6
+ from .transport import Transport
7
+ from .types import Ensured, Identity
8
+
9
+ __all__ = ["SourceClient"]
10
+
11
+
12
+ class SourceClient:
13
+ def __init__(self, transport: Transport, signer: RSASigner):
14
+ self._tr = transport
15
+ self._signer = signer
16
+
17
+ async def ensure(self, ident: Identity) -> Ensured:
18
+ """Idempotently adopt an external identity. ``auth_instance_id`` must
19
+ equal the signing source (server enforces, else PermissionDenied)."""
20
+ body = {
21
+ "auth_instance_id": ident.auth_instance_id,
22
+ "kind": ident.kind,
23
+ "principal_key": ident.key,
24
+ }
25
+ data = await self._tr.post_json("/v1/principals/ensure", body, self._signer)
26
+ return Ensured(
27
+ data.get("epid", ""),
28
+ data.get("canonical_kind", ""),
29
+ bool(data.get("created", False)),
30
+ )
31
+
32
+ async def link(self, ident: Identity, target_epid: str) -> None:
33
+ body = {
34
+ "auth_instance_id": ident.auth_instance_id,
35
+ "kind": ident.kind,
36
+ "principal_key": ident.key,
37
+ "target_epid": target_epid,
38
+ }
39
+ await self._tr.post_json("/v1/principals/link", body, self._signer)
40
+
41
+ async def affiliate(self, subject_epid: str, relation: str, target_epid: str) -> None:
42
+ body = {"subject_epid": subject_epid, "relation": relation, "target_epid": target_epid}
43
+ await self._tr.post_json("/v1/principals/affiliate", body, self._signer)
44
+
45
+ def rotate_key(self, signer: RSASigner) -> None:
46
+ self._signer = signer
@@ -0,0 +1,79 @@
1
+ """Async HTTP transport: signed/unsigned JSON calls + unified error decoding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from typing import Any, Callable, Optional
8
+
9
+ import httpx
10
+
11
+ from .errors import Unavailable, classify_status
12
+ from .signing import RSASigner
13
+
14
+ # F4 source-signed write headers (authoritative server names; the CHP-005 sketch
15
+ # used X-Clearing-Source-Id and omitted the timestamp — both corrected here).
16
+ HEADER_SOURCE = "X-Clearing-Source"
17
+ HEADER_SIGNATURE = "X-Clearing-Signature"
18
+ HEADER_TIMESTAMP = "X-Clearing-Timestamp"
19
+
20
+
21
+ class Transport:
22
+ def __init__(
23
+ self,
24
+ base_url: str,
25
+ client: httpx.AsyncClient,
26
+ now: Callable[[], float] = time.time,
27
+ ):
28
+ self._base = base_url.rstrip("/")
29
+ self._client = client
30
+ self._now = now
31
+
32
+ async def post_json(
33
+ self,
34
+ path: str,
35
+ body: dict,
36
+ signer: Optional[RSASigner] = None,
37
+ *,
38
+ forbid_float: bool = True,
39
+ ) -> dict:
40
+ # Serialize once; sign the exact bytes we send (server canonicalizes).
41
+ raw = json.dumps(body, ensure_ascii=False).encode("utf-8")
42
+ headers = {"Content-Type": "application/json"}
43
+ if signer is not None:
44
+ headers[HEADER_SIGNATURE] = signer.sign_b64(body, forbid_float=forbid_float)
45
+ headers[HEADER_SOURCE] = signer.source_id
46
+ headers[HEADER_TIMESTAMP] = str(int(self._now()))
47
+ try:
48
+ resp = await self._client.post(self._base + path, content=raw, headers=headers)
49
+ except httpx.HTTPError as e:
50
+ raise Unavailable(f"clearing: transport error: {e}") from e
51
+ return self._decode(resp)
52
+
53
+ async def get_json(self, path: str) -> dict:
54
+ try:
55
+ resp = await self._client.get(self._base + path)
56
+ except httpx.HTTPError as e:
57
+ raise Unavailable(f"clearing: transport error: {e}") from e
58
+ return self._decode(resp)
59
+
60
+ def _decode(self, resp: httpx.Response) -> dict:
61
+ if resp.status_code != 200:
62
+ code, detail = _envelope(resp)
63
+ raise classify_status(resp.status_code, code, detail)
64
+ if not resp.content:
65
+ return {}
66
+ try:
67
+ return resp.json()
68
+ except (json.JSONDecodeError, ValueError) as e:
69
+ raise Unavailable(f"clearing: bad response body: {e}") from e
70
+
71
+
72
+ def _envelope(resp: httpx.Response) -> tuple[str, str]:
73
+ try:
74
+ data: Any = resp.json()
75
+ if isinstance(data, dict):
76
+ return str(data.get("error", "")), str(data.get("detail", ""))
77
+ except (json.JSONDecodeError, ValueError):
78
+ pass
79
+ return "", ""
clearing_sdk/types.py ADDED
@@ -0,0 +1,75 @@
1
+ """Public data types for the Clearing Python SDK (mirror the Go SDK shape)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ __all__ = [
8
+ "Identity",
9
+ "Resolved",
10
+ "Ensured",
11
+ "AttrAssertion",
12
+ "DedupResult",
13
+ "RELATION_MEMBER_OF",
14
+ "RELATION_ACCOUNTABLE_FOR",
15
+ ]
16
+
17
+ RELATION_MEMBER_OF = "member_of"
18
+ RELATION_ACCOUNTABLE_FOR = "accountable_for"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Identity:
23
+ """External identity natural key (realm is NOT part of the key, F1)."""
24
+
25
+ auth_instance_id: str
26
+ kind: str
27
+ key: str
28
+
29
+ def cache_key(self) -> str:
30
+ return f"{self.auth_instance_id}\x1f{self.kind}\x1f{self.key}"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Resolved:
35
+ epid: str
36
+ canonical_kind: str
37
+ status: str # ACTIVE | MERGED | SUSPENDED
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Ensured:
42
+ epid: str
43
+ canonical_kind: str
44
+ created: bool
45
+
46
+
47
+ @dataclass
48
+ class AttrAssertion:
49
+ """Verifier deduplication assertion. ``verifier_sig`` is filled by the SDK."""
50
+
51
+ epid: str
52
+ attr_type: str
53
+ salted_hash: str
54
+ assurance_tier: int = 0
55
+ method: str = ""
56
+
57
+ def signed_body(self, verifier_id: str) -> dict:
58
+ """The exact field set the server re-marshals for verification.
59
+
60
+ verifier_sig is excluded (server field is json:"-")."""
61
+ return {
62
+ "epid": self.epid,
63
+ "attr_type": self.attr_type,
64
+ "salted_hash": self.salted_hash,
65
+ "assurance_tier": self.assurance_tier,
66
+ "method": self.method,
67
+ "verifier_id": verifier_id,
68
+ }
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class DedupResult:
73
+ active_epid: str
74
+ merged: bool
75
+ needs_review: bool = False
clearing_sdk/unify.py ADDED
@@ -0,0 +1,122 @@
1
+ """L3 unification tier: key-proof / verified-attr / binding / realm-link.
2
+
3
+ Hides the challenge fetch->sign->submit dance and the Ed25519/RSA + base64(std)
4
+ footguns. Holds the source RSA key (verified-attr verifier_sig + realm-link F4)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+
10
+ from cryptography.hazmat.primitives.asymmetric import ed25519
11
+
12
+ from .signing import RSASigner, ed25519_public_b64, ed25519_sign_b64, key_fingerprint
13
+ from .transport import Transport
14
+ from .types import AttrAssertion, DedupResult, Identity
15
+
16
+ __all__ = ["UnifyClient"]
17
+
18
+ PURPOSE_KEY_PROOF = "key-proof"
19
+
20
+
21
+ class UnifyClient:
22
+ def __init__(self, transport: Transport, signer: RSASigner):
23
+ self._tr = transport
24
+ self._signer = signer
25
+
26
+ async def prove_key(self, epid: str, ed_priv: ed25519.Ed25519PrivateKey) -> DedupResult:
27
+ """Deduplicate by key control: fetch challenge, sign with Ed25519, submit
28
+ public key + fingerprint + signature. Same key across sources => same
29
+ principal."""
30
+ challenge = await self._challenge(epid, PURPOSE_KEY_PROOF)
31
+ body = {
32
+ "epid": epid,
33
+ "key_id": key_fingerprint(ed_priv),
34
+ "public_key": ed25519_public_b64(ed_priv),
35
+ "challenge": challenge,
36
+ "signature": ed25519_sign_b64(ed_priv, challenge),
37
+ }
38
+ return await self._post_dedup("/v1/unify/key-proof", body)
39
+
40
+ async def submit_verified_attr(self, a: AttrAssertion) -> DedupResult:
41
+ """Sign the canonical assertion body WITHOUT verifier_sig (server field is
42
+ json:"-") with the source RSA key, attach it base64(std)."""
43
+ signed = a.signed_body(self._signer.source_id)
44
+ sig = self._signer.sign_b64(signed)
45
+ body = dict(signed)
46
+ body["verifier_sig"] = sig
47
+ return await self._post_dedup("/v1/unify/verified-attr", body)
48
+
49
+ async def bind(
50
+ self,
51
+ subject_epid: str,
52
+ target_epid: str,
53
+ subject_key: ed25519.Ed25519PrivateKey,
54
+ target_key: ed25519.Ed25519PrivateKey,
55
+ ) -> DedupResult:
56
+ """Dual-binding merge: both sides sign the same challenge."""
57
+ challenge = await self._start_binding(subject_epid, target_epid, "dual_key")
58
+ body = {
59
+ "challenge": challenge,
60
+ "subject_public_key": ed25519_public_b64(subject_key),
61
+ "subject_signature": ed25519_sign_b64(subject_key, challenge),
62
+ "target_public_key": ed25519_public_b64(target_key),
63
+ "target_signature": ed25519_sign_b64(target_key, challenge),
64
+ }
65
+ return await self._post_dedup("/v1/unify/binding/prove", body)
66
+
67
+ async def link_realm(
68
+ self,
69
+ org_epid: str,
70
+ realm: Identity,
71
+ admin_key: ed25519.Ed25519PrivateKey,
72
+ ) -> None:
73
+ """Project an org realm identity into the org EPID. Whole request is
74
+ F4-signed by the realm's source; admin control proven by signing the
75
+ canonical org-admin message with the org's Ed25519 admin key."""
76
+ msg = _org_admin_message(org_epid, realm)
77
+ body = {
78
+ "org_epid": org_epid,
79
+ "realm_identity": {
80
+ "auth_instance_id": realm.auth_instance_id,
81
+ "kind": realm.kind,
82
+ "principal_key": realm.key,
83
+ },
84
+ "admin_proof": {
85
+ "public_key": ed25519_public_b64(admin_key),
86
+ "signature": base64.standard_b64encode(
87
+ admin_key.sign(msg.encode("utf-8"))
88
+ ).decode("ascii"),
89
+ },
90
+ }
91
+ await self._tr.post_json("/v1/unify/realm-link", body, self._signer)
92
+
93
+ async def _challenge(self, epid: str, purpose: str) -> str:
94
+ data = await self._tr.post_json("/v1/unify/challenge", {"epid": epid, "purpose": purpose})
95
+ return data.get("challenge", "")
96
+
97
+ async def _start_binding(self, subject_epid: str, target_epid: str, method: str) -> str:
98
+ data = await self._tr.post_json(
99
+ "/v1/unify/binding",
100
+ {"subject_epid": subject_epid, "target_epid": target_epid, "method": method},
101
+ )
102
+ return data.get("challenge", "")
103
+
104
+ async def _post_dedup(self, path: str, body: dict) -> DedupResult:
105
+ data = await self._tr.post_json(path, body)
106
+ return DedupResult(
107
+ data.get("active_epid", ""),
108
+ bool(data.get("merged", False)),
109
+ bool(data.get("needs_review", False)),
110
+ )
111
+
112
+
113
+ def key_fingerprint_pub(raw_pub: bytes) -> str:
114
+ """Fingerprint a raw Ed25519 public key (mirrors internal/unify.KeyFingerprint)."""
115
+ import hashlib
116
+
117
+ return "sha256:" + hashlib.sha256(raw_pub).hexdigest()
118
+
119
+
120
+ def _org_admin_message(org_epid: str, realm: Identity) -> str:
121
+ """Mirror internal/unify.orgAdminMessage (wire contract)."""
122
+ return f"realm-link|{org_epid}|{realm.auth_instance_id}|{realm.kind}|{realm.key}"
@@ -0,0 +1,75 @@
1
+ Metadata-Version: 2.4
2
+ Name: jetv-clearing-sdk
3
+ Version: 1.0.0
4
+ Summary: Official Python SDK for the Clearing economic-principal (EPID) service
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://github.com/jetv/clearing-sdk-python
7
+ Project-URL: Source, https://github.com/jetv/clearing-sdk-python
8
+ Keywords: clearing,epid,identity,sdk,jetv
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: httpx>=0.27
16
+ Requires-Dist: cryptography>=42
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=8; extra == "test"
19
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
20
+
21
+ # jetv-clearing-sdk
22
+
23
+ Official Python SDK for the **Clearing** economic-principal (EPID) service.
24
+
25
+ Clearing assigns every economic principal — humans, services, agents, organizations,
26
+ and providers — a stable **EPID** and a canonical kind, and exposes signed, auditable
27
+ operations for resolution, sourced writes, and identity unification.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install jetv-clearing-sdk
33
+ ```
34
+
35
+ ## Tiers
36
+
37
+ The SDK is layered so each caller only takes the capability (and trust) it needs:
38
+
39
+ | Tier | Constructor | Capability |
40
+ | --- | --- | --- |
41
+ | L1 — read | `ClearingClient.read_only(...)` | resolve EPIDs, read canonical kinds (no signing keys) |
42
+ | L2 — source | `ClearingClient.source(...)` | F4-signed `ensure` / `link` / `affiliate` writes |
43
+ | L3 — unify | `ClearingClient.unify(...)` | key-proof, verified-attribute, bind, and realm-link flows |
44
+
45
+ ## Quick start (L1)
46
+
47
+ ```python
48
+ import asyncio
49
+ from clearing_sdk import ClearingClient
50
+
51
+ async def main():
52
+ client = ClearingClient.read_only(base_url="https://clearing.internal")
53
+ resolved = await client.resolve("user", "alice@example.com")
54
+ print(resolved.epid, resolved.canonical_kind, resolved.status)
55
+
56
+ asyncio.run(main())
57
+ ```
58
+
59
+ ## Canonical JSON & signing
60
+
61
+ For source-authenticated (F4) and unify (L2/L3) operations the SDK produces a
62
+ **Go-exact canonical JSON** encoding so signatures verify byte-for-byte against the
63
+ server reference implementation, including HTML escaping of `<`, `>`, `&` and
64
+ `U+2028` / `U+2029`. Floating-point numbers are rejected in signed bodies to keep
65
+ canonicalization deterministic.
66
+
67
+ ## Resilience
68
+
69
+ The L1 read client ships an in-process TTL cache, single-flight request
70
+ deduplication, and a circuit breaker with zero required third-party runtime deps
71
+ beyond `httpx` and `cryptography`.
72
+
73
+ ## License
74
+
75
+ Apache-2.0
@@ -0,0 +1,17 @@
1
+ clearing_sdk/__init__.py,sha256=aku_d8_qRJucHsWhHYqVPl5sZxfqcU4p6RrZxsUHOHA,1527
2
+ clearing_sdk/_resilience.py,sha256=XHAqzqPJ0haVT_OH1zeqAnw88qM8ZzmseWbjPSYTyj0,2471
3
+ clearing_sdk/canonical.py,sha256=eVml6Ft22zWr29FmlQJS7ac9PlJXeN8xG8KMWfYNGdk,4447
4
+ clearing_sdk/client.py,sha256=XOtnXHZbgiCdCctkp1n-kk_7Sp3RHL0T0gaMtEsDiJ0,2490
5
+ clearing_sdk/errors.py,sha256=jov5dTOsusvtqF69w4khj1kIZtcaUfuUOwsLNEFugjs,2731
6
+ clearing_sdk/kinds.py,sha256=cKB-JZdbyySPBufc8c7xBgpTXHQktwhmtqTfEEoSZcM,2114
7
+ clearing_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ clearing_sdk/resolve.py,sha256=MuB3dNml9pwDuJy9vr2MAIc-1vg0980x9udUxjR6UnY,3742
9
+ clearing_sdk/signing.py,sha256=E0AbXzmstz1qzusezfCkQLQMKI2z-730dxlYjxMpepY,2436
10
+ clearing_sdk/source.py,sha256=2pqSJX2T8JtjCRk-kh9ze1YI6aZqrbDwEXRCT3gup9I,1695
11
+ clearing_sdk/transport.py,sha256=FaBAWzSFghmswfZTZI5IZUhXV-c4G6fAE6yEsKuH920,2667
12
+ clearing_sdk/types.py,sha256=nJrx1CXRWeWIZ9b54AUV0eMIHoPL2kh5KH0ukCUB3Bo,1685
13
+ clearing_sdk/unify.py,sha256=I9YISUCSEgGjaLH-kr9hXMJLeVPTKGihxIH2rLoSyT8,4884
14
+ jetv_clearing_sdk-1.0.0.dist-info/METADATA,sha256=HIFU3k_aoKbZl-iL2ucFqERGQ1ViIG20BZIoJ1JrM7U,2572
15
+ jetv_clearing_sdk-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
16
+ jetv_clearing_sdk-1.0.0.dist-info/top_level.txt,sha256=uhUBP-Q1Z25q9jig_ypsHHDNUMwNx3XfZBPAr2kytMw,13
17
+ jetv_clearing_sdk-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ clearing_sdk