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.
- clearing_sdk/__init__.py +60 -0
- clearing_sdk/_resilience.py +83 -0
- clearing_sdk/canonical.py +149 -0
- clearing_sdk/client.py +79 -0
- clearing_sdk/errors.py +105 -0
- clearing_sdk/kinds.py +63 -0
- clearing_sdk/py.typed +0 -0
- clearing_sdk/resolve.py +105 -0
- clearing_sdk/signing.py +72 -0
- clearing_sdk/source.py +46 -0
- clearing_sdk/transport.py +79 -0
- clearing_sdk/types.py +75 -0
- clearing_sdk/unify.py +122 -0
- jetv_clearing_sdk-1.0.0.dist-info/METADATA +75 -0
- jetv_clearing_sdk-1.0.0.dist-info/RECORD +17 -0
- jetv_clearing_sdk-1.0.0.dist-info/WHEEL +5 -0
- jetv_clearing_sdk-1.0.0.dist-info/top_level.txt +1 -0
clearing_sdk/__init__.py
ADDED
|
@@ -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
|
clearing_sdk/resolve.py
ADDED
|
@@ -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
|
clearing_sdk/signing.py
ADDED
|
@@ -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 @@
|
|
|
1
|
+
clearing_sdk
|