chitmark 0.1.1__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.
chitmark/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """Official Python SDK for Chitmark by Open Agent Ledger.
2
+
3
+ Trust decisions on agent-mediated actions (verify, feedback, challenge),
4
+ tuned by business outcomes.
5
+ """
6
+
7
+ from chitmark.client import SDK_VERSION, Chitmark
8
+ from chitmark.errors import ChitmarkApiError, ChitmarkError
9
+ from chitmark.pii import hash_email_sha256, truncate_ip
10
+ from chitmark.types import (
11
+ ActorType,
12
+ ChallengeMethod,
13
+ ChallengeResponse,
14
+ Decision,
15
+ FeedbackResponse,
16
+ Outcome,
17
+ Principal,
18
+ Subject,
19
+ Verdict,
20
+ VerdictTokenClaims,
21
+ )
22
+ from chitmark.verify_token import VerdictTokenError, verify_verdict_token
23
+
24
+ __all__ = [
25
+ "SDK_VERSION",
26
+ "ActorType",
27
+ "ChallengeMethod",
28
+ "ChallengeResponse",
29
+ "Chitmark",
30
+ "ChitmarkApiError",
31
+ "ChitmarkError",
32
+ "Decision",
33
+ "FeedbackResponse",
34
+ "Outcome",
35
+ "Principal",
36
+ "Subject",
37
+ "Verdict",
38
+ "VerdictTokenClaims",
39
+ "VerdictTokenError",
40
+ "hash_email_sha256",
41
+ "truncate_ip",
42
+ "verify_verdict_token",
43
+ ]
44
+
45
+ __version__ = SDK_VERSION
chitmark/client.py ADDED
@@ -0,0 +1,346 @@
1
+ """Official Python client for Chitmark by Open Agent Ledger.
2
+
3
+ Trust decisions on agent-mediated actions, tuned by business outcomes.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from collections.abc import Mapping
10
+ from typing import Any, cast
11
+
12
+ import httpx
13
+
14
+ from chitmark.errors import ChitmarkApiError, ChitmarkError
15
+ from chitmark.pii import hash_email_sha256, minimize_form_data, truncate_ip
16
+ from chitmark.types import (
17
+ ChallengeCompletionResponse,
18
+ ChallengeMethod,
19
+ ChallengeProof,
20
+ ChallengeResponse,
21
+ ChitmarkEnv,
22
+ Decision,
23
+ FeedbackResponse,
24
+ OnDegraded,
25
+ Outcome,
26
+ PiiMode,
27
+ Subject,
28
+ Verdict,
29
+ )
30
+
31
+ __all__ = ["Chitmark", "DEFAULT_BASE_URLS", "SDK_VERSION"]
32
+
33
+ SDK_VERSION = "0.1.0"
34
+
35
+ DEFAULT_BASE_URLS: dict[ChitmarkEnv, str] = {
36
+ "production": "https://api.chitmark.com",
37
+ "staging": "https://api.staging.chitmark.com",
38
+ "development": "http://127.0.0.1:8787",
39
+ }
40
+
41
+ DEFAULT_TIMEOUT_MS = 800
42
+
43
+
44
+ def _degraded_challenge_verdict(reason: str, session: str | None = None) -> Verdict:
45
+ """Golden rule: fail to challenge, never to allow."""
46
+ return {
47
+ "decision": "challenge",
48
+ "actorType": "unknown",
49
+ "confidence": 0.0,
50
+ "reasons": [reason],
51
+ "principal": {"resolved": False, "identityBasis": ["degraded"]},
52
+ "verdictToken": "",
53
+ "eventId": f"evt_degraded_{session or 'unknown'}",
54
+ "degraded": True,
55
+ "enforcementMode": "enforce",
56
+ }
57
+
58
+
59
+ def _enforce_degraded_never_allow(verdict: Verdict) -> Verdict:
60
+ if verdict.get("degraded") and verdict.get("decision") == "allow":
61
+ reasons = list(verdict.get("reasons") or [])
62
+ reasons.append("Client enforced: degraded decision must not be allow")
63
+ return {**verdict, "decision": "challenge", "reasons": reasons}
64
+ return verdict
65
+
66
+
67
+ def _is_well_formed_verdict(value: Any) -> bool:
68
+ """Minimal shape guard for a 200 verify response.
69
+
70
+ A malformed verdict must never be treated as allow — the gating fields
71
+ are what gate correctness (parity with the TypeScript SDK).
72
+ """
73
+ if not isinstance(value, dict):
74
+ return False
75
+ return (
76
+ value.get("decision") in {"allow", "challenge", "deny"}
77
+ and isinstance(value.get("eventId"), str)
78
+ and bool(value["eventId"])
79
+ and isinstance(value.get("degraded"), bool)
80
+ )
81
+
82
+
83
+ def _prepare_subject(subject: Subject | Mapping[str, Any], pii_mode: PiiMode) -> Subject:
84
+ data = dict(subject)
85
+ if pii_mode == "raw":
86
+ return cast(Subject, data)
87
+
88
+ if pii_mode == "none":
89
+ headers = data.get("headers")
90
+ kept: Subject = {}
91
+ if data.get("userAgent"):
92
+ kept["userAgent"] = str(data["userAgent"])
93
+ if isinstance(headers, dict):
94
+ wb: dict[str, str] = {}
95
+ keys = {"signature-agent", "signature-input", "signature"}
96
+ for k, v in headers.items():
97
+ lk = str(k).lower()
98
+ if lk in keys and isinstance(v, str):
99
+ wb[lk] = v
100
+ if wb:
101
+ kept["headers"] = wb
102
+ return kept
103
+
104
+ prepared: Subject = {}
105
+ if data.get("userAgent") is not None:
106
+ prepared["userAgent"] = str(data["userAgent"])
107
+ if isinstance(data.get("headers"), dict):
108
+ prepared["headers"] = {str(k): str(v) for k, v in data["headers"].items()}
109
+
110
+ if data.get("emailSha256"):
111
+ prepared["emailSha256"] = str(data["emailSha256"])
112
+ elif data.get("email"):
113
+ prepared["emailSha256"] = hash_email_sha256(str(data["email"]))
114
+
115
+ if data.get("ipTrunc"):
116
+ prepared["ipTrunc"] = str(data["ipTrunc"])
117
+ elif data.get("ip"):
118
+ prepared["ipTrunc"] = truncate_ip(str(data["ip"]))
119
+
120
+ form_hashed = data.get("formDataHashed")
121
+ if form_hashed is None and isinstance(data.get("formData"), dict):
122
+ form_hashed = minimize_form_data(data["formData"])
123
+ if form_hashed:
124
+ prepared["formDataHashed"] = dict(form_hashed)
125
+
126
+ return prepared
127
+
128
+
129
+ class Chitmark:
130
+ """
131
+ Chitmark client — three verbs: verify, feedback, challenge.
132
+
133
+ Example::
134
+
135
+ from chitmark import Chitmark
136
+
137
+ client = Chitmark(api_key="ck_live_...")
138
+ verdict = client.verify(
139
+ action="signup",
140
+ subject={"email": "buyer@acme.com", "ip": "203.0.113.7"},
141
+ )
142
+ """
143
+
144
+ def __init__(
145
+ self,
146
+ api_key: str,
147
+ *,
148
+ base_url: str | None = None,
149
+ env: ChitmarkEnv = "production",
150
+ timeout_ms: int = DEFAULT_TIMEOUT_MS,
151
+ pii_mode: PiiMode = "hashed",
152
+ on_degraded: OnDegraded = "challenge",
153
+ client_name: str | None = None,
154
+ http_client: httpx.Client | None = None,
155
+ ) -> None:
156
+ if not api_key:
157
+ raise ChitmarkError("Chitmark api_key is required")
158
+ if on_degraded != "challenge":
159
+ raise ChitmarkError(
160
+ 'on_degraded must be "challenge" — fail to challenge, never to allow'
161
+ )
162
+
163
+ self._api_key = api_key
164
+ self._base_url = (
165
+ base_url or os.environ.get("CHITMARK_BASE_URL") or DEFAULT_BASE_URLS[env]
166
+ ).rstrip("/")
167
+ self._timeout = timeout_ms / 1000.0
168
+ self._pii_mode = pii_mode
169
+ self._on_degraded: OnDegraded = "challenge"
170
+ self._client_name = client_name or f"chitmark-python/{SDK_VERSION}"
171
+ self._owns_client = http_client is None
172
+ self._http = http_client or httpx.Client(
173
+ base_url=self._base_url,
174
+ timeout=self._timeout,
175
+ headers={
176
+ "Authorization": f"Bearer {self._api_key}",
177
+ "Content-Type": "application/json",
178
+ "Accept": "application/json",
179
+ "User-Agent": self._client_name,
180
+ "X-Chitmark-Client": self._client_name,
181
+ },
182
+ )
183
+
184
+ def close(self) -> None:
185
+ """Close the underlying HTTP client if owned by this instance."""
186
+ if self._owns_client:
187
+ self._http.close()
188
+
189
+ def __enter__(self) -> Chitmark:
190
+ return self
191
+
192
+ def __exit__(self, *args: object) -> None:
193
+ self.close()
194
+
195
+ def verify(
196
+ self,
197
+ *,
198
+ action: str,
199
+ subject: Subject | Mapping[str, Any],
200
+ session: str | None = None,
201
+ surface: str | None = None,
202
+ context: Mapping[str, Any] | None = None,
203
+ idempotency_key: str | None = None,
204
+ ) -> Verdict:
205
+ """Score + route an inbound action. Degrades to challenge on timeout/transport."""
206
+ body: dict[str, Any] = {
207
+ "action": action,
208
+ "subject": _prepare_subject(subject, self._pii_mode),
209
+ }
210
+ if session is not None:
211
+ body["session"] = session
212
+ if surface is not None:
213
+ body["surface"] = surface
214
+ if context is not None:
215
+ body["context"] = dict(context)
216
+ if idempotency_key is not None:
217
+ body["idempotencyKey"] = idempotency_key
218
+
219
+ try:
220
+ verdict = self._request(
221
+ "POST",
222
+ "/v1/verify",
223
+ body=body,
224
+ idempotency_key=idempotency_key,
225
+ )
226
+ if not _is_well_formed_verdict(verdict):
227
+ return _degraded_challenge_verdict(
228
+ "Degraded: verify returned a malformed verdict shape",
229
+ session,
230
+ )
231
+ return _enforce_degraded_never_allow(cast(Verdict, verdict))
232
+ except ChitmarkApiError:
233
+ raise
234
+ except Exception as exc: # noqa: BLE001 — map transport to degraded
235
+ return _degraded_challenge_verdict(f"Degraded: {exc}", session)
236
+
237
+ def feedback(
238
+ self,
239
+ *,
240
+ event_id: str,
241
+ outcome: Outcome,
242
+ value: float | None = None,
243
+ unit: str = "usd",
244
+ observed_at: str | None = None,
245
+ ) -> FeedbackResponse:
246
+ """Report a downstream outcome joined on eventId."""
247
+ body: dict[str, Any] = {"eventId": event_id, "outcome": outcome}
248
+ if value is not None:
249
+ body["value"] = value
250
+ if unit is not None:
251
+ body["unit"] = unit
252
+ if observed_at is not None:
253
+ body["observedAt"] = observed_at
254
+ return cast(
255
+ FeedbackResponse,
256
+ self._request("POST", "/v1/feedback", body=body),
257
+ )
258
+
259
+ def challenge(
260
+ self,
261
+ *,
262
+ event_id: str,
263
+ session: str | None = None,
264
+ prefer: list[ChallengeMethod] | None = None,
265
+ ) -> ChallengeResponse:
266
+ """Issue an asymmetric-cost challenge."""
267
+ body: dict[str, Any] = {"eventId": event_id}
268
+ if session is not None:
269
+ body["session"] = session
270
+ if prefer is not None:
271
+ body["prefer"] = prefer
272
+ return cast(
273
+ ChallengeResponse,
274
+ self._request("POST", "/v1/challenge", body=body),
275
+ )
276
+
277
+ def complete_challenge(
278
+ self,
279
+ *,
280
+ event_id: str,
281
+ challenge_id: str,
282
+ session: str | None = None,
283
+ proof: ChallengeProof,
284
+ ) -> ChallengeCompletionResponse:
285
+ """Complete a challenge by submitting its solved proof.
286
+
287
+ Clears only the bound session, once (anti-replay). Re-verify with
288
+ ``context={"challengeId": challenge_id}`` so the next verify honors
289
+ the cleared session.
290
+ """
291
+ body: dict[str, Any] = {
292
+ "eventId": event_id,
293
+ "challengeId": challenge_id,
294
+ "proof": proof,
295
+ }
296
+ if session is not None:
297
+ body["session"] = session
298
+ return cast(
299
+ ChallengeCompletionResponse,
300
+ self._request("POST", "/v1/challenge", body=body),
301
+ )
302
+
303
+ def _request(
304
+ self,
305
+ method: str,
306
+ path: str,
307
+ *,
308
+ body: dict[str, Any] | None = None,
309
+ idempotency_key: str | None = None,
310
+ ) -> Any:
311
+ headers: dict[str, str] = {}
312
+ if idempotency_key:
313
+ headers["Idempotency-Key"] = idempotency_key
314
+ try:
315
+ res = self._http.request(method, path, json=body, headers=headers)
316
+ except httpx.TimeoutException as exc:
317
+ raise ChitmarkError(
318
+ f"Request timed out after {int(self._timeout * 1000)}ms",
319
+ code="timeout",
320
+ ) from exc
321
+ except httpx.HTTPError as exc:
322
+ raise ChitmarkError(str(exc), code="transport") from exc
323
+
324
+ parsed: Any
325
+ try:
326
+ parsed = res.json() if res.content else None
327
+ except ValueError:
328
+ parsed = res.text
329
+
330
+ if res.is_error:
331
+ raise ChitmarkApiError(res.status_code, parsed)
332
+
333
+ return parsed
334
+
335
+ @property
336
+ def config(self) -> dict[str, Any]:
337
+ return {
338
+ "base_url": self._base_url,
339
+ "timeout_ms": int(self._timeout * 1000),
340
+ "pii_mode": self._pii_mode,
341
+ "on_degraded": self._on_degraded,
342
+ }
343
+
344
+
345
+ # Re-export Decision for callers
346
+ __all__ += ["Decision"]
chitmark/errors.py ADDED
@@ -0,0 +1,45 @@
1
+ """Chitmark client errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class ChitmarkError(Exception):
9
+ """Base error for all Chitmark client failures."""
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ *,
15
+ status: int | None = None,
16
+ code: str | None = None,
17
+ body: Any = None,
18
+ ) -> None:
19
+ super().__init__(message)
20
+ self.status = status
21
+ self.code = code
22
+ self.body = body
23
+
24
+
25
+ class ChitmarkApiError(ChitmarkError):
26
+ """Raised when the API returns a non-2xx response."""
27
+
28
+ def __init__(
29
+ self,
30
+ status: int,
31
+ body: Any,
32
+ message: str | None = None,
33
+ ) -> None:
34
+ if message is None:
35
+ if isinstance(body, dict):
36
+ message = str(
37
+ body.get("message") or body.get("error") or f"HTTP {status}",
38
+ )
39
+ else:
40
+ message = str(body) or f"HTTP {status}"
41
+ code = None
42
+ if isinstance(body, dict):
43
+ raw = body.get("code") or body.get("error")
44
+ code = str(raw) if raw is not None else None
45
+ super().__init__(message, status=status, code=code, body=body)
chitmark/pii.py ADDED
@@ -0,0 +1,54 @@
1
+ """Client-side PII minimization helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ from typing import Any
8
+
9
+
10
+ def hash_email_sha256(email: str) -> str:
11
+ """SHA-256 hex digest of lowercased, trimmed email."""
12
+ normalized = email.strip().lower().encode("utf-8")
13
+ return hashlib.sha256(normalized).hexdigest()
14
+
15
+
16
+ def truncate_ip(ip: str) -> str:
17
+ """Truncate IPv4 to /24 or IPv6 to /48."""
18
+ first = ip.strip().split(",")[0].strip()
19
+ if "." in first and ":" not in first:
20
+ parts = first.split(".")
21
+ if len(parts) == 4:
22
+ return f"{parts[0]}.{parts[1]}.{parts[2]}.0/24"
23
+ if ":" in first:
24
+ hextets = [h for h in first.split(":") if h]
25
+ head = ":".join(hextets[:3])
26
+ return f"{head}::/48"
27
+ return first
28
+
29
+
30
+ _ALLOWED_FORM = {
31
+ "companyDomain",
32
+ "company_domain",
33
+ "domain",
34
+ "plan",
35
+ "referrer",
36
+ "utm_source",
37
+ "utm_medium",
38
+ "utm_campaign",
39
+ }
40
+
41
+
42
+ def minimize_form_data(form_data: dict[str, Any] | None) -> dict[str, Any] | None:
43
+ """Keep only allowed non-PII form keys."""
44
+ if not form_data:
45
+ return None
46
+ out = {k: v for k, v in form_data.items() if k in _ALLOWED_FORM and v is not None}
47
+ return out or None
48
+
49
+
50
+ _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
51
+
52
+
53
+ def looks_like_email(value: str) -> bool:
54
+ return bool(_EMAIL_RE.match(value.strip()))
chitmark/py.typed ADDED
@@ -0,0 +1 @@
1
+ # Marker for PEP 561 typed package
chitmark/types.py ADDED
@@ -0,0 +1,172 @@
1
+ """Chitmark Python SDK — type definitions aligned with openapi/openapi.yaml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal, TypedDict
6
+
7
+ Decision = Literal["allow", "challenge", "deny"]
8
+ ActorType = Literal["human", "agent_principal", "agent_abusive", "unknown"]
9
+ EnforcementMode = Literal["enforce", "log_only"]
10
+ Action = (
11
+ Literal[
12
+ "signup",
13
+ "trial_activation",
14
+ "api_key",
15
+ "demo_request",
16
+ "quote",
17
+ "ticket",
18
+ "custom",
19
+ ]
20
+ | str
21
+ )
22
+ Outcome = Literal[
23
+ "credit_burn",
24
+ "multi_account_cluster",
25
+ "abuse_confirmed",
26
+ "converted",
27
+ "chargeback",
28
+ "churned",
29
+ "false_positive",
30
+ ]
31
+ ChallengeMethod = Literal[
32
+ "payment_preauth",
33
+ "proof_of_work",
34
+ "device_attestation",
35
+ "web_bot_auth_stepup",
36
+ "email_otp",
37
+ ]
38
+ PiiMode = Literal["raw", "hashed", "none"]
39
+ ChitmarkEnv = Literal["production", "staging", "development"]
40
+ OnDegraded = Literal["challenge"]
41
+
42
+
43
+ class PrincipalRequired(TypedDict):
44
+ """Required principal fields from verify."""
45
+ resolved: bool
46
+ identityBasis: list[str]
47
+
48
+
49
+ class Principal(PrincipalRequired, total=False):
50
+ """Principal resolution from verify."""
51
+
52
+ operator: str
53
+ onBehalfOf: str
54
+
55
+
56
+ class Verdict(TypedDict):
57
+ """Verdict object — single source of truth for a verify call."""
58
+
59
+ decision: Decision
60
+ actorType: ActorType
61
+ confidence: float
62
+ reasons: list[str]
63
+ principal: Principal
64
+ verdictToken: str
65
+ eventId: str
66
+ degraded: bool
67
+ enforcementMode: EnforcementMode
68
+
69
+
70
+ class VerdictTokenClaims(TypedDict, total=False):
71
+ """Decoded verdictToken JWT payload (see SDK.md §6)."""
72
+
73
+ eventId: str
74
+ decision: Decision
75
+ actorType: ActorType
76
+ confidence: float
77
+ aud: str
78
+ sub: str
79
+ jti: str
80
+ iat: int
81
+ exp: int
82
+ degraded: bool
83
+
84
+
85
+ class VerdictWithChallenge(Verdict, total=False):
86
+ """Optional verdict additions for cleared challenges."""
87
+
88
+ challengeCleared: bool
89
+
90
+
91
+ class Subject(TypedDict, total=False):
92
+ """Subject signals; prefer hashed fields over raw PII."""
93
+
94
+ email: str
95
+ emailSha256: str
96
+ ip: str
97
+ ipTrunc: str
98
+ userAgent: str
99
+ headers: dict[str, str]
100
+ formData: dict[str, Any]
101
+ formDataHashed: dict[str, Any]
102
+
103
+
104
+ class VerifyParams(TypedDict, total=False):
105
+ """Parameters for Client.verify."""
106
+
107
+ idempotency_key: str
108
+ action: Action
109
+ session: str
110
+ surface: str
111
+ subject: Subject
112
+ context: dict[str, Any]
113
+
114
+
115
+ class FeedbackParams(TypedDict, total=False):
116
+ """Parameters for Client.feedback."""
117
+
118
+ event_id: str
119
+ outcome: Outcome
120
+ value: float
121
+ unit: str
122
+ observed_at: str
123
+
124
+
125
+ class ChallengeParams(TypedDict, total=False):
126
+ """Parameters for Client.challenge (issue mode)."""
127
+
128
+ event_id: str
129
+ session: str
130
+ prefer: list[ChallengeMethod]
131
+
132
+
133
+ class CompleteChallengeParams(TypedDict, total=False):
134
+ """Parameters for Client.complete_challenge (completion mode)."""
135
+
136
+ event_id: str
137
+ challenge_id: str
138
+ session: str
139
+ proof: ChallengeProof
140
+
141
+
142
+ class ProofOfWorkChallengeProof(TypedDict):
143
+ type: Literal["proof_of_work"]
144
+ nonce: str
145
+
146
+
147
+ class StripeSetupIntentChallengeProof(TypedDict):
148
+ type: Literal["stripe_setup_intent"]
149
+ setupIntentId: str
150
+
151
+
152
+ ChallengeProof = ProofOfWorkChallengeProof | StripeSetupIntentChallengeProof
153
+
154
+
155
+ class FeedbackResponse(TypedDict):
156
+ ok: bool
157
+ eventId: str
158
+
159
+
160
+ class ChallengeResponse(TypedDict):
161
+ challengeId: str
162
+ method: ChallengeMethod
163
+ instructions: dict[str, Any]
164
+ boundTo: str
165
+ expiresAt: str
166
+
167
+
168
+ class ChallengeCompletionResponse(TypedDict):
169
+ ok: bool
170
+ challengeId: str
171
+ boundTo: str
172
+ clearedAt: str
@@ -0,0 +1,228 @@
1
+ """Client-side verdictToken verification (MVP-016).
2
+
3
+ A verdictToken is an ES256 JWT decision receipt minted by the edge and bound
4
+ to session (``sub``), origin (``aud``), and ``jti`` with a short ``exp``. This
5
+ module validates shape, signature (against the JWKS served from
6
+ ``GET /.well-known/jwks.json``), expiry, session binding, and — when an
7
+ expected audience is supplied — origin binding. Rejection reasons are surfaced
8
+ as ``VerdictTokenError.code``.
9
+
10
+ Requires the optional extras ``PyJWT`` and ``cryptography``
11
+ (``uv sync --extra verdict``).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Mapping
17
+ from typing import Any
18
+
19
+ import httpx
20
+
21
+ from chitmark.errors import ChitmarkError
22
+
23
+ try: # pragma: no cover - imported lazily; extras are optional
24
+ import jwt as pyjwt
25
+ from jwt.exceptions import (
26
+ DecodeError,
27
+ ExpiredSignatureError,
28
+ ImmatureSignatureError,
29
+ InvalidSignatureError,
30
+ )
31
+ except ImportError: # pragma: no cover
32
+ pyjwt = None # type: ignore[assignment]
33
+ DecodeError = ExpiredSignatureError = ImmatureSignatureError = Exception # type: ignore[misc,assignment]
34
+ InvalidSignatureError = Exception # type: ignore[misc,assignment]
35
+
36
+
37
+ class VerdictTokenError(ChitmarkError):
38
+ """Raised when a verdictToken fails verification."""
39
+
40
+
41
+ DEFAULT_JWKS_URL = "https://api.chitmark.com/.well-known/jwks.json"
42
+ DEFAULT_LEEWAY_SECONDS = 30
43
+
44
+
45
+ def verify_verdict_token(
46
+ token: str,
47
+ *,
48
+ session: str | None = None,
49
+ aud: str | None = None,
50
+ jwks: Mapping[str, Any] | None = None,
51
+ jwks_url: str = DEFAULT_JWKS_URL,
52
+ http_client: httpx.Client | None = None,
53
+ leeway_seconds: int = DEFAULT_LEEWAY_SECONDS,
54
+ ) -> dict[str, Any]:
55
+ """Verify a verdictToken signature and binding.
56
+
57
+ Args:
58
+ token: The ES256 JWT from a verify verdict.
59
+ session: Expected session; token ``sub`` must equal this.
60
+ aud: Expected origin; token ``aud`` must equal this when provided.
61
+ jwks: Pre-fetched JWKS mapping; skips the network fetch.
62
+ jwks_url: JWKS endpoint; defaults to the production API.
63
+ http_client: Reusable ``httpx.Client`` for the JWKS fetch.
64
+ leeway_seconds: Clock-skew tolerance.
65
+
66
+ Raises:
67
+ VerdictTokenError: with a machine-readable ``code``:
68
+ ``malformed_token``, ``wrong_algorithm``, ``jwks_unavailable``,
69
+ ``unknown_kid``, ``invalid_signature``, ``expired``,
70
+ ``not_yet_valid``, ``wrong_session``, or ``wrong_aud``.
71
+ """
72
+ if pyjwt is None: # pragma: no cover
73
+ raise VerdictTokenError(
74
+ "verdict token verification requires the optional extras "
75
+ "`PyJWT` and `cryptography` (uv sync --extra verdict)",
76
+ code="dependency_missing",
77
+ )
78
+
79
+ header = _decode_header(token)
80
+
81
+ alg = header.get("alg")
82
+ if alg != "ES256":
83
+ raise VerdictTokenError(
84
+ f"verdictToken must use ES256, got {alg!r}",
85
+ code="wrong_algorithm",
86
+ )
87
+ kid = header.get("kid")
88
+ if not isinstance(kid, str) or not kid:
89
+ raise VerdictTokenError(
90
+ "verdictToken is missing kid",
91
+ code="unknown_kid",
92
+ )
93
+
94
+ key = _find_jwk(kid, jwks=jwks, jwks_url=jwks_url, http_client=http_client)
95
+ if key is None:
96
+ raise VerdictTokenError(
97
+ f"JWKS has no key for kid {kid}",
98
+ code="unknown_kid",
99
+ )
100
+
101
+ pem_key = _jwk_to_pem(key)
102
+ try:
103
+ claims = pyjwt.decode(
104
+ token,
105
+ key=pem_key,
106
+ algorithms=["ES256"],
107
+ options={"verify_aud": False},
108
+ leeway=leeway_seconds,
109
+ )
110
+ except ExpiredSignatureError:
111
+ raise VerdictTokenError(
112
+ "verdictToken has expired", code="expired"
113
+ ) from None
114
+ except ImmatureSignatureError:
115
+ raise VerdictTokenError(
116
+ "verdictToken iat is in the future",
117
+ code="not_yet_valid",
118
+ ) from None
119
+ except InvalidSignatureError:
120
+ raise VerdictTokenError(
121
+ "verdictToken signature failed verification",
122
+ code="invalid_signature",
123
+ ) from None
124
+ except DecodeError as exc:
125
+ raise VerdictTokenError(
126
+ f"verdictToken could not be decoded: {exc}",
127
+ code="malformed_token",
128
+ ) from None
129
+
130
+ if session is not None and claims.get("sub") != session:
131
+ raise VerdictTokenError(
132
+ "verdictToken was minted for a different session",
133
+ code="wrong_session",
134
+ )
135
+ if aud is not None and claims.get("aud") != aud:
136
+ raise VerdictTokenError(
137
+ "verdictToken was minted for a different origin",
138
+ code="wrong_aud",
139
+ )
140
+ return claims
141
+
142
+
143
+ def _jwk_to_pem(jwk: Mapping[str, Any]) -> bytes:
144
+ """Convert an EC public JWK (P-256) to a PEM subject-public-key."""
145
+ import base64
146
+
147
+ from cryptography.hazmat.primitives import serialization
148
+ from cryptography.hazmat.primitives.asymmetric import ec
149
+
150
+ try:
151
+ x = int.from_bytes(base64.urlsafe_b64decode(jwk["x"] + "=="), "big")
152
+ y = int.from_bytes(base64.urlsafe_b64decode(jwk["y"] + "=="), "big")
153
+ public = ec.EllipticCurvePublicNumbers(x, y, ec.SECP256R1()).public_key()
154
+ except (KeyError, ValueError, TypeError) as exc:
155
+ raise VerdictTokenError(
156
+ "JWKS key is not a valid P-256 public key",
157
+ code="invalid_signature",
158
+ ) from exc
159
+ return public.public_bytes(
160
+ serialization.Encoding.PEM,
161
+ serialization.PublicFormat.SubjectPublicKeyInfo,
162
+ )
163
+
164
+
165
+ def _decode_header(token: str) -> dict[str, Any]:
166
+ try:
167
+ header = pyjwt.get_unverified_header(token)
168
+ except Exception as exc:
169
+ raise VerdictTokenError(
170
+ f"verdictToken is not a valid JWT: {exc}",
171
+ code="malformed_token",
172
+ ) from None
173
+ if not isinstance(header, dict):
174
+ raise VerdictTokenError(
175
+ "verdictToken header is malformed",
176
+ code="malformed_token",
177
+ )
178
+ return header
179
+
180
+
181
+ def _find_jwk(
182
+ kid: str,
183
+ *,
184
+ jwks: Mapping[str, Any] | None,
185
+ jwks_url: str,
186
+ http_client: httpx.Client | None,
187
+ ) -> Mapping[str, Any] | None:
188
+ if jwks is None:
189
+ try:
190
+ client = http_client or httpx.Client(timeout=10.0)
191
+ owns_client = http_client is None
192
+ try:
193
+ res = client.get(jwks_url)
194
+ finally:
195
+ if owns_client:
196
+ client.close()
197
+ except httpx.HTTPError as exc:
198
+ raise VerdictTokenError(
199
+ f"Failed to load JWKS from {jwks_url}: {exc}",
200
+ code="jwks_unavailable",
201
+ ) from None
202
+ if res.status_code != 200:
203
+ raise VerdictTokenError(
204
+ f"Failed to load JWKS from {jwks_url}: HTTP {res.status_code}",
205
+ code="jwks_unavailable",
206
+ ) from None
207
+ try:
208
+ jwks = res.json()
209
+ except ValueError as exc:
210
+ raise VerdictTokenError(
211
+ f"JWKS response is not JSON: {exc}",
212
+ code="jwks_unavailable",
213
+ ) from None
214
+ if not isinstance(jwks, Mapping):
215
+ raise VerdictTokenError(
216
+ "JWKS response has no keys array",
217
+ code="jwks_unavailable",
218
+ )
219
+ keys = jwks.get("keys")
220
+ if not isinstance(keys, list):
221
+ raise VerdictTokenError(
222
+ "JWKS response has no keys array",
223
+ code="jwks_unavailable",
224
+ )
225
+ for key in keys:
226
+ if isinstance(key, Mapping) and key.get("kid") == kid:
227
+ return key
228
+ return None
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: chitmark
3
+ Version: 0.1.1
4
+ Summary: Official Python SDK for Chitmark by Open Agent Ledger — trust decisions on agent-mediated actions (verify, feedback, challenge)
5
+ Project-URL: Homepage, https://chitmark.com
6
+ Project-URL: Documentation, https://chitmark.com/docs
7
+ Project-URL: Repository, https://github.com/nonameuserd/Chitmark
8
+ Project-URL: Issues, https://github.com/nonameuserd/Chitmark/issues
9
+ Author-email: Open Agent Ledger <dev@chitmark.com>
10
+ License: Proprietary — no open-source grant. Copyright (c) 2026 Open Agent Ledger. All rights reserved.
11
+ License-File: LICENSE
12
+ Keywords: agent-actions,anti-abuse,chitmark,open-agent-ledger,trust-decisions
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: httpx>=0.27.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: cryptography>=43.0.0; extra == 'dev'
25
+ Requires-Dist: mypy>=1.14.0; extra == 'dev'
26
+ Requires-Dist: pyjwt>=2.9.0; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.25.0; extra == 'dev'
28
+ Requires-Dist: pytest>=8.3.0; extra == 'dev'
29
+ Requires-Dist: respx>=0.22.0; extra == 'dev'
30
+ Requires-Dist: ruff>=0.9.0; extra == 'dev'
31
+ Provides-Extra: verdict
32
+ Requires-Dist: cryptography>=43.0.0; extra == 'verdict'
33
+ Requires-Dist: pyjwt>=2.9.0; extra == 'verdict'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # chitmark (Python)
37
+
38
+ Official Python SDK for Chitmark — trust decisions on agent-mediated actions, tuned by business outcomes (`verify` / `feedback` / `challenge`).
39
+
40
+ [![PyPI version](https://img.shields.io/pypi/v/chitmark.svg)](https://pypi.org/project/chitmark/)
41
+
42
+ Requires Python ≥ 3.10.
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install chitmark
48
+ # or
49
+ uv add chitmark
50
+ ```
51
+
52
+ ## Quick start
53
+
54
+ ```python
55
+ from chitmark import Chitmark
56
+
57
+ client = Chitmark(api_key="ck_live_...")
58
+
59
+ verdict = client.verify(
60
+ action="signup",
61
+ session="sess_9f3a",
62
+ surface="app.acme.com/signup",
63
+ subject={
64
+ "email": "buyer@acmecorp.com",
65
+ "ip": "203.0.113.7",
66
+ "userAgent": "...",
67
+ },
68
+ )
69
+ # Persist verdict["eventId"] on the account row — feedback joins only on that id.
70
+ # Default pii_mode="hashed" minimizes email/IP before the wire.
71
+ ```
72
+
73
+ ## API
74
+
75
+ Three verbs:
76
+
77
+ | Method | Endpoint | Purpose |
78
+ | :---------------------- | :------------------- | :------- |
79
+ | `client.verify(...)` | `POST /v1/verify` | Decide |
80
+ | `client.feedback(...)` | `POST /v1/feedback` | Learn |
81
+ | `client.challenge(...)` | `POST /v1/challenge` | Escalate |
82
+
83
+ **Golden rule:** fail to `challenge`, never to `allow`. Timeouts return a degraded challenge verdict.
84
+
85
+ ### Feedback
86
+
87
+ Attribution is a schema contract, not a lookup: store the `eventId` from
88
+ `verify` on the account row, then report outcomes against that same id — never
89
+ a derived or guessed id.
90
+
91
+ ```python
92
+ # At signup: persist the join key
93
+ verdict = client.verify(action="signup", subject={"email": "a@b.com"})
94
+ db.accounts.update(user_id, chitmark_event_id=verdict["eventId"])
95
+
96
+ # Later, when a label matures (credit burn, chargeback, …):
97
+ client.feedback(
98
+ event_id=account.chitmark_event_id, # the stored join key — never guessed
99
+ outcome="credit_burn",
100
+ value=87.4,
101
+ unit="usd",
102
+ observed_at="2026-08-06T04:00:00Z",
103
+ )
104
+ ```
105
+
106
+ ### PII
107
+
108
+ | Mode | Behavior |
109
+ | :------------ | :------------------------------------------------------ |
110
+ | `hashed` (default) | SHA-256 email, /24 IP truncation, minimized form fields |
111
+ | `none` | Derived/header-shape signals only |
112
+ | `raw` | Tenant opt-in only; higher compliance review |
113
+
114
+ ## Develop
115
+
116
+ ```bash
117
+ cd packages/sdk-python
118
+ uv sync --extra dev # or: pip install -e ".[dev]"
119
+ pytest
120
+ ruff check .
121
+ ```
122
+
123
+ ## Resources
124
+
125
+ - [API reference](https://chitmark.com/docs)
126
+ - [GitHub](https://github.com/nonameuserd/Chitmark)
127
+
128
+ ## License
129
+
130
+ Proprietary — see [LICENSE](LICENSE).
@@ -0,0 +1,11 @@
1
+ chitmark/__init__.py,sha256=xfaenhYdB5VUHa3v9nmYa-J95Ac7g9a3B_paplYmO8Y,1021
2
+ chitmark/client.py,sha256=XiXcihyd8W-kzAh5yMx2AwIZzsO1dtI5CRzXN4FW-6E,10984
3
+ chitmark/errors.py,sha256=7SW9Bo7GxCOz0TOUejt7iMmU2aAD4iI4KyGJrn70OGQ,1201
4
+ chitmark/pii.py,sha256=uoIMXzE-8TI6H1vS3iYNEJXRs0LzktySZuLkuNpMbnk,1380
5
+ chitmark/py.typed,sha256=gwldFK6aqFdEKlk7Vh3cPNTuEAohX5TTDi8oEN2SqMo,35
6
+ chitmark/types.py,sha256=_KXIyfQF4kr8ALRCEnHDOAcQq2_FdRQpruWUfsZTM1o,3669
7
+ chitmark/verify_token.py,sha256=E3Tmz2LnNpLQVpdqI9S0wEyy3PxhAgdyep-xgS-QtBA,7623
8
+ chitmark-0.1.1.dist-info/METADATA,sha256=W13dWyrhZ6D_QVg5pSuB10LTd8zqMBu1yPczUoVgHyU,4211
9
+ chitmark-0.1.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
10
+ chitmark-0.1.1.dist-info/licenses/LICENSE,sha256=Ha5knm4RASNOC0LKQvBeU3cmzHmhrLPaxmncOqkX9kQ,877
11
+ chitmark-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,15 @@
1
+ Copyright (c) 2026 Open Agent Ledger. All rights reserved.
2
+
3
+ Proprietary license. You are granted a non-exclusive, non-transferable,
4
+ worldwide right to use, modify, and integrate this software in applications
5
+ that communicate with the Chitmark API. Redistribution of this software or
6
+ its modifications — in whole or in part — and sublicensing are prohibited
7
+ without prior written consent from Open Agent Ledger.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
10
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
11
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
12
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
13
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
14
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
15
+ SOFTWARE.