didis-py 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ name: CI
2
+ on: { push: { branches: [main] }, pull_request: {} }
3
+ permissions: { contents: read }
4
+ jobs:
5
+ test:
6
+ runs-on: ubuntu-latest
7
+ strategy: { matrix: { python: ["3.11", "3.12", "3.13"] } }
8
+ steps:
9
+ - uses: actions/checkout@v4
10
+ - uses: actions/setup-python@v5
11
+ with: { python-version: "${{ matrix.python }}" }
12
+ - run: pip install httpx mypy build
13
+ - run: mypy --strict didis
14
+ - run: python -m unittest discover -s tests
15
+ - run: python -m build
@@ -0,0 +1,27 @@
1
+ name: Publish to PyPI
2
+ on:
3
+ release: { types: [published] }
4
+ permissions: { contents: read }
5
+ jobs:
6
+ build:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - uses: actions/checkout@v4
10
+ - uses: actions/setup-python@v5
11
+ with: { python-version: "3.12" }
12
+ - run: pip install httpx mypy build
13
+ - run: mypy --strict didis && python -m unittest discover -s tests
14
+ - name: Tag must match package version
15
+ run: test "v$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")" = "${GITHUB_REF_NAME}"
16
+ - run: python -m build
17
+ - uses: actions/upload-artifact@v4
18
+ with: { name: dist, path: dist/ }
19
+ publish:
20
+ needs: build
21
+ runs-on: ubuntu-latest
22
+ environment: pypi
23
+ permissions: { id-token: write } # PyPI trusted publishing; no API token
24
+ steps:
25
+ - uses: actions/download-artifact@v4
26
+ with: { name: dist, path: dist/ }
27
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,5 @@
1
+ __pycache__/
2
+ dist/
3
+ *.egg-info/
4
+ .venv/
5
+ .mypy_cache/
didis_py-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ledgerbingo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.5
2
+ Name: didis-py
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the DID.is public API: DID resolution with evidence, credentials, policies and agent trust
5
+ Project-URL: Homepage, https://did.is
6
+ Project-URL: Documentation, https://did.is/developers
7
+ Project-URL: Repository, https://github.com/did-is/didis-py
8
+ Project-URL: Issues, https://github.com/did-is/didis-py/issues
9
+ Author: DID.is
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: a2a,decentralized-identifiers,did,mcp,resolver,verifiable-credentials,w3c
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
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.11
22
+ Requires-Dist: httpx>=0.27.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # didis-py
26
+
27
+ Official Python SDK for the [DID.is](https://did.is) public API: DID resolution with evidence, W3C DID Resolution, verifiable credentials, explicit policies, and MCP/A2A agent trust. Sync and async clients on `httpx`; Python 3.11+. Responses are plain `dict`s typed with `TypedDict` (`didis.types`), so editors and mypy know their shape.
28
+
29
+ The distribution is `didis-py`; the import name is `didis`.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install didis-py
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```python
40
+ from didis import DidisClient, AsyncDidisClient, DidisError
41
+
42
+ # Defaults to the public API at https://did.is/api; pass base_url when self-hosting.
43
+ with DidisClient() as didis:
44
+ r = didis.resolve("did:web:identity.foundation")
45
+ print(r["verdict"]["headline"])
46
+ for d in r["dimensions"]:
47
+ print(d["label"], d["state"])
48
+
49
+ # DID Resolution v1 (W3C CR Draft HTTPS binding, unmodified)
50
+ w3c = didis.resolve_w3c("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK")
51
+
52
+ # Live resolution stream (server-sent events)
53
+ for event, data in didis.stream("did:web:identity.foundation"):
54
+ if event == "stage":
55
+ print(data["label"], data["status"])
56
+
57
+ # Credentials: VC DM 2.0 / 1.1, Data Integrity (eddsa-jcs-2022, ecdsa-jcs-2019), VC-JOSE/VC-JWT
58
+ vc = didis.verify_credential(credential)
59
+ print(vc["status"], vc["headline"])
60
+
61
+ # Policies are explicit rules, never scores
62
+ out = didis.evaluate_policy(
63
+ "did:web:example.com",
64
+ {"rules": {"allowedMethods": ["web", "webvh"], "domainBinding": "required"}},
65
+ )
66
+ print(out["evaluation"]["status"])
67
+
68
+ # Agents
69
+ mcp = didis.inspect_mcp("https://mcp.example.com/mcp")
70
+ print(mcp["status"], mcp["inventoryHash"], len(mcp["tools"]))
71
+ decision = didis.authorize(chain, "payments.refund", trusted_roots=["did:web:example.com"])
72
+ print(decision["authorization"]["decision"])
73
+
74
+ try:
75
+ didis.resolve("did:web:does-not-exist.invalid")
76
+ except DidisError as e:
77
+ print(e.status, e.problem.get("code"), e.problem.get("detail"))
78
+
79
+
80
+ async def main() -> None:
81
+ async with AsyncDidisClient() as didis:
82
+ diff = await didis.diff("did:web:example.com")
83
+ print(diff)
84
+ ```
85
+
86
+ Identifiers are percent-encoded exactly once (`quote(did, safe="")`). Non-2xx responses raise `DidisError` with the RFC 9457 problem body.
87
+
88
+ ## Webhooks
89
+
90
+ Monitoring routes need `admin_token=` and belong on servers only. Verify deliveries with:
91
+
92
+ ```python
93
+ from didis import verify_webhook_signature
94
+
95
+ ok = verify_webhook_signature(
96
+ secret,
97
+ request.headers["x-didis-timestamp"],
98
+ raw_body,
99
+ request.headers["x-didis-signature-256"],
100
+ )
101
+ ```
102
+
103
+ Public API reference: <https://did.is/developers>. Monitoring routes need `admin_token=` and are only available on self-hosted deployments; they are not exposed by the public API.
104
+
105
+ ## License
106
+
107
+ MIT
@@ -0,0 +1,83 @@
1
+ # didis-py
2
+
3
+ Official Python SDK for the [DID.is](https://did.is) public API: DID resolution with evidence, W3C DID Resolution, verifiable credentials, explicit policies, and MCP/A2A agent trust. Sync and async clients on `httpx`; Python 3.11+. Responses are plain `dict`s typed with `TypedDict` (`didis.types`), so editors and mypy know their shape.
4
+
5
+ The distribution is `didis-py`; the import name is `didis`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install didis-py
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ from didis import DidisClient, AsyncDidisClient, DidisError
17
+
18
+ # Defaults to the public API at https://did.is/api; pass base_url when self-hosting.
19
+ with DidisClient() as didis:
20
+ r = didis.resolve("did:web:identity.foundation")
21
+ print(r["verdict"]["headline"])
22
+ for d in r["dimensions"]:
23
+ print(d["label"], d["state"])
24
+
25
+ # DID Resolution v1 (W3C CR Draft HTTPS binding, unmodified)
26
+ w3c = didis.resolve_w3c("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK")
27
+
28
+ # Live resolution stream (server-sent events)
29
+ for event, data in didis.stream("did:web:identity.foundation"):
30
+ if event == "stage":
31
+ print(data["label"], data["status"])
32
+
33
+ # Credentials: VC DM 2.0 / 1.1, Data Integrity (eddsa-jcs-2022, ecdsa-jcs-2019), VC-JOSE/VC-JWT
34
+ vc = didis.verify_credential(credential)
35
+ print(vc["status"], vc["headline"])
36
+
37
+ # Policies are explicit rules, never scores
38
+ out = didis.evaluate_policy(
39
+ "did:web:example.com",
40
+ {"rules": {"allowedMethods": ["web", "webvh"], "domainBinding": "required"}},
41
+ )
42
+ print(out["evaluation"]["status"])
43
+
44
+ # Agents
45
+ mcp = didis.inspect_mcp("https://mcp.example.com/mcp")
46
+ print(mcp["status"], mcp["inventoryHash"], len(mcp["tools"]))
47
+ decision = didis.authorize(chain, "payments.refund", trusted_roots=["did:web:example.com"])
48
+ print(decision["authorization"]["decision"])
49
+
50
+ try:
51
+ didis.resolve("did:web:does-not-exist.invalid")
52
+ except DidisError as e:
53
+ print(e.status, e.problem.get("code"), e.problem.get("detail"))
54
+
55
+
56
+ async def main() -> None:
57
+ async with AsyncDidisClient() as didis:
58
+ diff = await didis.diff("did:web:example.com")
59
+ print(diff)
60
+ ```
61
+
62
+ Identifiers are percent-encoded exactly once (`quote(did, safe="")`). Non-2xx responses raise `DidisError` with the RFC 9457 problem body.
63
+
64
+ ## Webhooks
65
+
66
+ Monitoring routes need `admin_token=` and belong on servers only. Verify deliveries with:
67
+
68
+ ```python
69
+ from didis import verify_webhook_signature
70
+
71
+ ok = verify_webhook_signature(
72
+ secret,
73
+ request.headers["x-didis-timestamp"],
74
+ raw_body,
75
+ request.headers["x-didis-signature-256"],
76
+ )
77
+ ```
78
+
79
+ Public API reference: <https://did.is/developers>. Monitoring routes need `admin_token=` and are only available on self-hosted deployments; they are not exposed by the public API.
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,22 @@
1
+ """DID.is Official Python SDK."""
2
+
3
+ from . import types
4
+ from .client import (
5
+ DEFAULT_BASE_URL,
6
+ AsyncDidisClient,
7
+ DidisClient,
8
+ DidisError,
9
+ verify_customer_monitor_signature,
10
+ verify_webhook_signature,
11
+ )
12
+
13
+ __all__ = [
14
+ "DEFAULT_BASE_URL",
15
+ "DidisClient",
16
+ "AsyncDidisClient",
17
+ "DidisError",
18
+ "types",
19
+ "verify_webhook_signature",
20
+ "verify_customer_monitor_signature",
21
+ ]
22
+ __version__ = "0.1.0"
@@ -0,0 +1,338 @@
1
+ """DID.is client SDK for Python (sync and async, httpx).
2
+
3
+ Identifiers are percent-encoded exactly once; the resolver decodes them exactly once.
4
+ Errors raise ``DidisError`` carrying the RFC 9457 problem object returned by the API.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import hmac
11
+ import json
12
+ import time
13
+ from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Tuple, cast
14
+ from urllib.parse import quote
15
+
16
+ import httpx
17
+
18
+ from .types import (
19
+ A2aInspection,
20
+ AuthorizationResponse,
21
+ DelegationVerification,
22
+ DereferencingResult,
23
+ EnrichedResolution,
24
+ FastVerifyDidResult,
25
+ FastVerifyJwsResult,
26
+ GraphResponse,
27
+ HealthStatus,
28
+ HistoryResponse,
29
+ McpInspection,
30
+ Policy,
31
+ PolicyResponse,
32
+ ResolutionResult,
33
+ SemanticDiff,
34
+ ToolAuthorization,
35
+ CredentialVerification,
36
+ )
37
+
38
+ DEFAULT_BASE_URL = "https://did.is/api"
39
+ """Public DID.is API. Pass your own ``base_url`` when self-hosting."""
40
+
41
+ JSON = Dict[str, Any]
42
+
43
+
44
+ def _enc(value: str) -> str:
45
+ return quote(value.strip(), safe="")
46
+
47
+
48
+ class DidisError(Exception):
49
+ """Raised for non-2xx responses; ``problem`` is the RFC 9457 body."""
50
+
51
+ def __init__(self, status: int, problem: JSON):
52
+ self.status = status
53
+ self.problem = problem
54
+ super().__init__(f"{problem.get('title', 'HTTP ' + str(status))}: {problem.get('detail', '')}")
55
+
56
+
57
+ def _raise_for_problem(resp: httpx.Response) -> JSON:
58
+ if resp.is_success:
59
+ return resp.json() if resp.content else {}
60
+ try:
61
+ body = resp.json()
62
+ except ValueError:
63
+ body = {"title": f"HTTP {resp.status_code}", "detail": resp.text}
64
+ problem = body if isinstance(body, dict) else {"detail": str(body)}
65
+ retry_after = resp.headers.get("retry-after")
66
+ if retry_after is not None:
67
+ problem["retryAfter"] = retry_after
68
+ raise DidisError(resp.status_code, problem)
69
+
70
+
71
+ class _SseParser:
72
+ """Incremental text/event-stream parser yielding (event, json-or-text) per dispatched event."""
73
+
74
+ def __init__(self) -> None:
75
+ self.event = "message"
76
+ self.data: List[str] = []
77
+
78
+ def feed(self, line: str) -> Optional[Tuple[str, Any]]:
79
+ if line == "":
80
+ out: Optional[Tuple[str, Any]] = None
81
+ if self.data:
82
+ raw = "\n".join(self.data)
83
+ try:
84
+ out = (self.event, json.loads(raw))
85
+ except ValueError:
86
+ out = (self.event, raw)
87
+ self.event, self.data = "message", []
88
+ return out
89
+ if line.startswith("event:"):
90
+ self.event = line[6:].strip()
91
+ elif line.startswith("data:"):
92
+ value = line[5:]
93
+ self.data.append(value[1:] if value.startswith(" ") else value)
94
+ return None
95
+
96
+
97
+ def verify_webhook_signature(secret: str, timestamp: str, body: bytes, signature_header: str, tolerance_seconds: int = 300) -> bool:
98
+ """Verifies ``X-Didis-Signature-256 = sha256=HMAC(secret, timestamp + "." + body)``."""
99
+ try:
100
+ ts = int(timestamp)
101
+ except ValueError:
102
+ return False
103
+ if abs(time.time() - ts) > tolerance_seconds:
104
+ return False
105
+ mac = hmac.new(secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
106
+ return hmac.compare_digest(f"sha256={mac}", signature_header)
107
+
108
+
109
+ def verify_customer_monitor_signature(secret: str, body: bytes, signature: str, *, tenant_id: str, project_id: str, event_id: str, max_age_seconds: int = 30 * 86400) -> bool:
110
+ """Verify the durable CUSTOMER event format; receivers MUST durably deduplicate event IDs.
111
+
112
+ This creation timestamp is immutable on retry, unlike the existing admin format.
113
+ A valid signature does not establish DID authority or a paid entitlement.
114
+ """
115
+ import re
116
+ match = re.fullmatch(r"t=(0|[1-9][0-9]{0,15}),v1=([0-9a-f]{64})", signature)
117
+ if not match or not secret or len(body) > 300 * 1024 or type(max_age_seconds) is not int or not 1 <= max_age_seconds <= 30 * 86400:
118
+ return False
119
+ timestamp = int(match[1])
120
+ now = time.time()
121
+ if timestamp > 2**53 - 1 or timestamp > now + 60 or now - timestamp >= max_age_seconds:
122
+ return False
123
+ mac = hmac.new(secret.encode(), match[1].encode() + b"." + body, hashlib.sha256).hexdigest()
124
+ if not hmac.compare_digest(mac, match[2]):
125
+ return False
126
+ try:
127
+ event = json.loads(body)
128
+ return (isinstance(event, dict) and event.get("specVersion") == "didis.customer-monitor.v1"
129
+ and type(event.get("createdAt")) is int and event["createdAt"] == timestamp
130
+ and re.fullmatch(r"cevent_[0-9a-f]{64}", event_id) is not None and event.get("id") == event_id
131
+ and re.fullmatch(r"tenant_[0-9a-f]{64}", tenant_id) is not None and event.get("tenantId") == tenant_id
132
+ and re.fullmatch(r"project_[0-9a-f]{64}", project_id) is not None and event.get("projectId") == project_id)
133
+ except (ValueError, UnicodeError):
134
+ return False
135
+
136
+
137
+ class DidisClient:
138
+ """Synchronous client."""
139
+
140
+ def __init__(self, base_url: str = DEFAULT_BASE_URL, admin_token: Optional[str] = None, timeout: float = 30.0):
141
+ self.base_url = base_url.rstrip("/")
142
+ self._admin_token = admin_token
143
+ self._client = httpx.Client(base_url=self.base_url, timeout=timeout, headers={"accept": "application/json"})
144
+
145
+ def _admin(self) -> Dict[str, str]:
146
+ if not self._admin_token:
147
+ raise ValueError("admin_token is required for monitoring routes")
148
+ return {"authorization": f"Bearer {self._admin_token}"}
149
+
150
+ def health(self) -> HealthStatus:
151
+ return cast(HealthStatus, _raise_for_problem(self._client.get("/health")))
152
+
153
+ def resolve(self, did: str, no_cache: bool = False) -> EnrichedResolution:
154
+ params = {"noCache": "true"} if no_cache else None
155
+ return cast(EnrichedResolution, _raise_for_problem(self._client.get(f"/v1/resolve/{_enc(did)}", params=params)))
156
+
157
+ def resolve_w3c(self, did: str) -> ResolutionResult:
158
+ return cast(ResolutionResult, _raise_for_problem(self._client.get(f"/1.0/identifiers/{_enc(did)}", headers={"accept": "application/did-resolution"})))
159
+
160
+ def dereference(self, did_url: str) -> DereferencingResult:
161
+ return cast(DereferencingResult, _raise_for_problem(self._client.get(f"/v1/dereference/{_enc(did_url)}")))
162
+
163
+ def stream(self, did: str, no_cache: bool = True) -> Iterator[Tuple[str, Any]]:
164
+ params = {"noCache": "true"} if no_cache else None
165
+ with self._client.stream("GET", f"/v1/stream/{_enc(did)}", params=params, headers={"accept": "text/event-stream"}) as resp:
166
+ if not resp.is_success:
167
+ resp.read()
168
+ _raise_for_problem(resp)
169
+ parser = _SseParser()
170
+ for line in resp.iter_lines():
171
+ item = parser.feed(line)
172
+ if item is not None:
173
+ yield item
174
+ if item[0] == "done":
175
+ return
176
+
177
+ def history(self, did: str) -> HistoryResponse:
178
+ return cast(HistoryResponse, _raise_for_problem(self._client.get(f"/v1/history/{_enc(did)}")))
179
+
180
+ def diff(self, did: str, from_hash: Optional[str] = None, to_hash: Optional[str] = None) -> SemanticDiff:
181
+ params = {"from": from_hash, "to": to_hash} if from_hash and to_hash else None
182
+ return cast(SemanticDiff, _raise_for_problem(self._client.get(f"/v1/diff/{_enc(did)}", params=params)))
183
+
184
+ def graph(self, did: str) -> GraphResponse:
185
+ return cast(GraphResponse, _raise_for_problem(self._client.get(f"/v1/graph/{_enc(did)}")))
186
+
187
+ def verify_credential(self, credential: Any, *, expected_audience: Optional[str] = None) -> CredentialVerification:
188
+ """Check JWT audience only when a recipient is supplied; raw JSON strings preserve member identity."""
189
+ body: JSON = {"credential": credential}
190
+ if expected_audience is not None:
191
+ body["expectedAudience"] = expected_audience
192
+ return cast(CredentialVerification, _raise_for_problem(self._client.post("/v1/credentials/verify", json=body)))
193
+
194
+ def evaluate_policy(self, did: str, policy: Policy, credential: Any = None) -> PolicyResponse:
195
+ body: JSON = {"did": did, "policy": dict(policy)}
196
+ if credential is not None:
197
+ body["credential"] = credential
198
+ return cast(PolicyResponse, _raise_for_problem(self._client.post("/v1/policies/evaluate", json=body)))
199
+
200
+ def inspect_mcp(self, endpoint: str) -> McpInspection:
201
+ return cast(McpInspection, _raise_for_problem(self._client.get("/v1/mcp/inspect", params={"endpoint": endpoint})))
202
+
203
+ def inspect_a2a(self, url: str) -> A2aInspection:
204
+ return cast(A2aInspection, _raise_for_problem(self._client.get("/v1/a2a/inspect", params={"url": url})))
205
+
206
+ def verify_delegation(self, chain: List[str], trusted_roots: Optional[List[str]] = None, register: bool = False) -> DelegationVerification:
207
+ return cast(DelegationVerification, _raise_for_problem(self._client.post("/v1/agents/verify-delegation", json={"chain": chain, "trustedRoots": trusted_roots, "register": register})))
208
+
209
+ def authorize(self, chain: List[str], tool: str, trusted_roots: Optional[List[str]] = None) -> AuthorizationResponse:
210
+ return cast(AuthorizationResponse, _raise_for_problem(self._client.post("/v1/agents/authorize", json={"chain": chain, "tool": tool, "trustedRoots": trusted_roots})))
211
+
212
+ def verify_tool(self, agent: str, tool: str, root: Optional[str] = None) -> ToolAuthorization:
213
+ params = {"agent": agent, "tool": tool, **({"root": root} if root else {})}
214
+ return cast(ToolAuthorization, _raise_for_problem(self._client.get("/v1/agents/verify-tool", params=params)))
215
+
216
+ def fast_verify_did(self, did: str) -> FastVerifyDidResult:
217
+ return cast(FastVerifyDidResult, _raise_for_problem(self._client.get(f"/v1/fast-verify/did/{_enc(did)}")))
218
+
219
+ def fast_verify_jws(self, jws: str, relationship: Optional[str] = None) -> FastVerifyJwsResult:
220
+ return cast(FastVerifyJwsResult, _raise_for_problem(self._client.post("/v1/fast-verify/jws", json={"jws": jws, "relationship": relationship})))
221
+
222
+ # Monitoring (admin token required)
223
+ def watch(self, did: str, webhook_url: str, interval_seconds: Optional[int] = None) -> JSON:
224
+ return _raise_for_problem(self._client.post("/v1/monitoring/watches", json={"did": did, "webhookUrl": webhook_url, "intervalSeconds": interval_seconds}, headers=self._admin()))
225
+
226
+ def watches(self) -> JSON:
227
+ return _raise_for_problem(self._client.get("/v1/monitoring/watches", headers=self._admin()))
228
+
229
+ def unwatch(self, watch_id: str) -> None:
230
+ _raise_for_problem(self._client.delete(f"/v1/monitoring/watches/{_enc(watch_id)}", headers=self._admin()))
231
+
232
+ def events(self, watch_id: Optional[str] = None) -> JSON:
233
+ params = {"watchId": watch_id} if watch_id else None
234
+ return _raise_for_problem(self._client.get("/v1/monitoring/events", params=params, headers=self._admin()))
235
+
236
+ def close(self) -> None:
237
+ self._client.close()
238
+
239
+ def __enter__(self) -> "DidisClient":
240
+ return self
241
+
242
+ def __exit__(self, *exc: object) -> None:
243
+ self.close()
244
+
245
+
246
+ class AsyncDidisClient:
247
+ """Asynchronous client (same surface as ``DidisClient``)."""
248
+
249
+ def __init__(self, base_url: str = DEFAULT_BASE_URL, admin_token: Optional[str] = None, timeout: float = 30.0):
250
+ self.base_url = base_url.rstrip("/")
251
+ self._admin_token = admin_token
252
+ self._client = httpx.AsyncClient(base_url=self.base_url, timeout=timeout, headers={"accept": "application/json"})
253
+
254
+ def _admin(self) -> Dict[str, str]:
255
+ if not self._admin_token:
256
+ raise ValueError("admin_token is required for monitoring routes")
257
+ return {"authorization": f"Bearer {self._admin_token}"}
258
+
259
+ async def health(self) -> HealthStatus:
260
+ return cast(HealthStatus, _raise_for_problem(await self._client.get("/health")))
261
+
262
+ async def resolve(self, did: str, no_cache: bool = False) -> EnrichedResolution:
263
+ params = {"noCache": "true"} if no_cache else None
264
+ return cast(EnrichedResolution, _raise_for_problem(await self._client.get(f"/v1/resolve/{_enc(did)}", params=params)))
265
+
266
+ async def resolve_w3c(self, did: str) -> ResolutionResult:
267
+ return cast(ResolutionResult, _raise_for_problem(await self._client.get(f"/1.0/identifiers/{_enc(did)}", headers={"accept": "application/did-resolution"})))
268
+
269
+ async def dereference(self, did_url: str) -> DereferencingResult:
270
+ return cast(DereferencingResult, _raise_for_problem(await self._client.get(f"/v1/dereference/{_enc(did_url)}")))
271
+
272
+ async def stream(self, did: str, no_cache: bool = True) -> AsyncIterator[Tuple[str, Any]]:
273
+ params = {"noCache": "true"} if no_cache else None
274
+ async with self._client.stream("GET", f"/v1/stream/{_enc(did)}", params=params, headers={"accept": "text/event-stream"}) as resp:
275
+ if not resp.is_success:
276
+ await resp.aread()
277
+ _raise_for_problem(resp)
278
+ parser = _SseParser()
279
+ async for line in resp.aiter_lines():
280
+ item = parser.feed(line)
281
+ if item is not None:
282
+ yield item
283
+ if item[0] == "done":
284
+ return
285
+
286
+ async def history(self, did: str) -> HistoryResponse:
287
+ return cast(HistoryResponse, _raise_for_problem(await self._client.get(f"/v1/history/{_enc(did)}")))
288
+
289
+ async def diff(self, did: str, from_hash: Optional[str] = None, to_hash: Optional[str] = None) -> SemanticDiff:
290
+ params = {"from": from_hash, "to": to_hash} if from_hash and to_hash else None
291
+ return cast(SemanticDiff, _raise_for_problem(await self._client.get(f"/v1/diff/{_enc(did)}", params=params)))
292
+
293
+ async def graph(self, did: str) -> GraphResponse:
294
+ return cast(GraphResponse, _raise_for_problem(await self._client.get(f"/v1/graph/{_enc(did)}")))
295
+
296
+ async def verify_credential(self, credential: Any, *, expected_audience: Optional[str] = None) -> CredentialVerification:
297
+ """Check JWT audience only when a recipient is supplied; raw JSON strings preserve member identity."""
298
+ body: JSON = {"credential": credential}
299
+ if expected_audience is not None:
300
+ body["expectedAudience"] = expected_audience
301
+ return cast(CredentialVerification, _raise_for_problem(await self._client.post("/v1/credentials/verify", json=body)))
302
+
303
+ async def evaluate_policy(self, did: str, policy: Policy, credential: Any = None) -> PolicyResponse:
304
+ body: JSON = {"did": did, "policy": dict(policy)}
305
+ if credential is not None:
306
+ body["credential"] = credential
307
+ return cast(PolicyResponse, _raise_for_problem(await self._client.post("/v1/policies/evaluate", json=body)))
308
+
309
+ async def inspect_mcp(self, endpoint: str) -> McpInspection:
310
+ return cast(McpInspection, _raise_for_problem(await self._client.get("/v1/mcp/inspect", params={"endpoint": endpoint})))
311
+
312
+ async def inspect_a2a(self, url: str) -> A2aInspection:
313
+ return cast(A2aInspection, _raise_for_problem(await self._client.get("/v1/a2a/inspect", params={"url": url})))
314
+
315
+ async def verify_delegation(self, chain: List[str], trusted_roots: Optional[List[str]] = None, register: bool = False) -> DelegationVerification:
316
+ return cast(DelegationVerification, _raise_for_problem(await self._client.post("/v1/agents/verify-delegation", json={"chain": chain, "trustedRoots": trusted_roots, "register": register})))
317
+
318
+ async def authorize(self, chain: List[str], tool: str, trusted_roots: Optional[List[str]] = None) -> AuthorizationResponse:
319
+ return cast(AuthorizationResponse, _raise_for_problem(await self._client.post("/v1/agents/authorize", json={"chain": chain, "tool": tool, "trustedRoots": trusted_roots})))
320
+
321
+ async def verify_tool(self, agent: str, tool: str, root: Optional[str] = None) -> ToolAuthorization:
322
+ params = {"agent": agent, "tool": tool, **({"root": root} if root else {})}
323
+ return cast(ToolAuthorization, _raise_for_problem(await self._client.get("/v1/agents/verify-tool", params=params)))
324
+
325
+ async def fast_verify_did(self, did: str) -> FastVerifyDidResult:
326
+ return cast(FastVerifyDidResult, _raise_for_problem(await self._client.get(f"/v1/fast-verify/did/{_enc(did)}")))
327
+
328
+ async def fast_verify_jws(self, jws: str, relationship: Optional[str] = None) -> FastVerifyJwsResult:
329
+ return cast(FastVerifyJwsResult, _raise_for_problem(await self._client.post("/v1/fast-verify/jws", json={"jws": jws, "relationship": relationship})))
330
+
331
+ async def close(self) -> None:
332
+ await self._client.aclose()
333
+
334
+ async def __aenter__(self) -> "AsyncDidisClient":
335
+ return self
336
+
337
+ async def __aexit__(self, *exc: object) -> None:
338
+ await self.close()
File without changes
@@ -0,0 +1,414 @@
1
+ """Wire types for the DID.is public API (camelCase JSON, returned as plain ``dict``).
2
+
3
+ These are ``TypedDict`` declarations: they give editors and type checkers the response shape
4
+ without changing runtime behaviour. Fields marked ``NotRequired`` may be absent. String states
5
+ are typed as ``str`` because the server may add new states in a minor release.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, List, Literal, NotRequired, Optional, Tuple, TypedDict
11
+
12
+ DimensionState = Literal["ESTABLISHED", "SELF_CERTIFYING", "NOT_ESTABLISHED", "FAILED", "INDETERMINATE", "NOT_APPLICABLE"]
13
+
14
+
15
+ class TraceStage(TypedDict):
16
+ stage: str
17
+ label: str
18
+ status: str
19
+ startedUs: int
20
+ durationUs: int
21
+ detail: str
22
+
23
+
24
+ class Problem(TypedDict, total=False):
25
+ type: str
26
+ title: str
27
+ status: int
28
+ detail: str
29
+ code: str
30
+ trace: List[TraceStage]
31
+ retryAfter: str
32
+
33
+
34
+ class Check(TypedDict):
35
+ id: str
36
+ label: str
37
+ status: str
38
+ detail: str
39
+
40
+
41
+ class EvidenceDimension(TypedDict):
42
+ id: str
43
+ label: str
44
+ state: DimensionState
45
+ statement: str
46
+ proves: str
47
+ doesNotProve: str
48
+
49
+
50
+ class Verdict(TypedDict):
51
+ outcome: str
52
+ headline: str
53
+ statements: List[str]
54
+
55
+
56
+ class KeyEvidence(TypedDict):
57
+ id: str
58
+ type: str
59
+ controller: NotRequired[str]
60
+ controllerIsSubject: bool
61
+ relationships: List[str]
62
+ status: str
63
+ curve: NotRequired[str]
64
+ keySizeBits: NotRequired[int]
65
+ jwkThumbprint: NotRequired[str]
66
+ multikey: NotRequired[str]
67
+ jwk: NotRequired[Dict[str, Any]]
68
+ detail: str
69
+
70
+
71
+ class DomainBindingEvidence(TypedDict):
72
+ status: str
73
+ origin: str
74
+ configurationUrl: str
75
+ linkedDids: List[str]
76
+ credentials: List[Dict[str, Any]]
77
+ detail: str
78
+
79
+
80
+ class Evidence(TypedDict):
81
+ source: Dict[str, Any]
82
+ document: Dict[str, Any]
83
+ keys: List[KeyEvidence]
84
+ services: List[Dict[str, Any]]
85
+ domainBinding: NotRequired[DomainBindingEvidence]
86
+ tls: NotRequired[Dict[str, Any]]
87
+ history: NotRequired[Dict[str, Any]]
88
+
89
+
90
+ class GraphNode(TypedDict):
91
+ id: str
92
+ kind: str
93
+ label: str
94
+ sublabel: NotRequired[str]
95
+ state: str
96
+ layer: int
97
+ digest: NotRequired[str]
98
+ facts: List[Tuple[str, str]]
99
+
100
+
101
+ # ``from`` is a Python keyword, so this TypedDict uses the functional form.
102
+ GraphEdge = TypedDict("GraphEdge", {"id": str, "from": str, "to": str, "relation": str, "state": str})
103
+
104
+
105
+ class Graph(TypedDict):
106
+ nodes: List[GraphNode]
107
+ edges: List[GraphEdge]
108
+
109
+
110
+ class EnrichedResolution(TypedDict):
111
+ did: str
112
+ method: str
113
+ didDocument: Optional[Dict[str, Any]]
114
+ didResolutionMetadata: Dict[str, Any]
115
+ didDocumentMetadata: Dict[str, Any]
116
+ verdict: Verdict
117
+ dimensions: List[EvidenceDimension]
118
+ evidence: Evidence
119
+ graph: Graph
120
+ trace: List[TraceStage]
121
+ limitations: List[str]
122
+ resolver: Dict[str, str]
123
+ observedAt: str
124
+ cached: bool
125
+
126
+
127
+ ResolutionResult = TypedDict(
128
+ "ResolutionResult",
129
+ {"@context": str, "didDocument": Optional[Dict[str, Any]], "didResolutionMetadata": Dict[str, Any], "didDocumentMetadata": Dict[str, Any]},
130
+ )
131
+
132
+ DereferencingResult = TypedDict(
133
+ "DereferencingResult",
134
+ {"@context": str, "dereferencingMetadata": Dict[str, Any], "contentStream": Any, "contentMetadata": Dict[str, Any]},
135
+ )
136
+
137
+
138
+ class HealthStatus(TypedDict):
139
+ status: str
140
+ name: str
141
+ version: str
142
+ time: str
143
+ supportedMethods: List[str]
144
+ standards: Dict[str, Any]
145
+ monitoring: NotRequired[bool]
146
+ rateLimited: NotRequired[bool]
147
+
148
+
149
+ class ObservationRecord(TypedDict):
150
+ id: int
151
+ did: str
152
+ documentHash: str
153
+ observedAt: str
154
+ lastSeenAt: str
155
+ seenCount: int
156
+ latencyMs: int
157
+ resolverVersion: str
158
+ domainBindingStatus: str
159
+ summary: Optional[Dict[str, Any]]
160
+
161
+
162
+ class HistoryResponse(TypedDict):
163
+ did: str
164
+ count: int
165
+ history: List[ObservationRecord]
166
+ note: str
167
+
168
+
169
+ class RelationshipChange(TypedDict):
170
+ relationship: str
171
+ added: List[str]
172
+ removed: List[str]
173
+
174
+
175
+ class SemanticDiff(TypedDict):
176
+ did: str
177
+ fromHash: str
178
+ toHash: str
179
+ fromObservedAt: str
180
+ toObservedAt: str
181
+ identical: bool
182
+ keysAdded: List[str]
183
+ keysRemoved: List[str]
184
+ keysRotated: List[str]
185
+ relationshipsChanged: List[RelationshipChange]
186
+ servicesAdded: List[str]
187
+ servicesRemoved: List[str]
188
+ servicesChanged: List[str]
189
+ controllerChanged: bool
190
+ alsoKnownAsChanged: bool
191
+ contextsChanged: bool
192
+ domainBindingFrom: str
193
+ domainBindingTo: str
194
+ domainBindingChanged: bool
195
+ changedMembers: List[str]
196
+ summary: List[str]
197
+ fromDocument: Any
198
+ toDocument: Any
199
+
200
+
201
+ class GraphResponse(TypedDict):
202
+ did: str
203
+ observedAt: str
204
+ verdict: Verdict
205
+ nodes: List[GraphNode]
206
+ edges: List[GraphEdge]
207
+
208
+
209
+ class CredentialVerification(TypedDict):
210
+ status: str
211
+ headline: str
212
+ format: str
213
+ dataModel: str
214
+ id: NotRequired[str]
215
+ issuer: NotRequired[str]
216
+ issuerName: NotRequired[str]
217
+ subject: NotRequired[str]
218
+ types: List[str]
219
+ validFrom: NotRequired[str]
220
+ validUntil: NotRequired[str]
221
+ checks: List[Check]
222
+ proof: NotRequired[Dict[str, Any]]
223
+ statusList: List[Dict[str, Any]]
224
+ issuerResolution: NotRequired[Dict[str, Any]]
225
+ claims: Any
226
+ credential: Any
227
+ limitations: List[str]
228
+ valid: bool
229
+ errors: List[str]
230
+
231
+
232
+ class PolicyRules(TypedDict, total=False):
233
+ didResolution: Literal["required", "optional"]
234
+ allowedMethods: List[str]
235
+ allowedCurves: List[str]
236
+ allowedKeySuites: List[str]
237
+ minKeyBits: int
238
+ requireKeyRelationship: str
239
+ domainBinding: Literal["required", "optional"]
240
+ tlsMinDaysRemaining: int
241
+ verifiableHistory: Literal["required"]
242
+ maxCacheAgeSeconds: int
243
+ credentialStatus: Literal["active"]
244
+ credentialIssuerMustBeSubject: bool
245
+
246
+
247
+ class Policy(TypedDict):
248
+ name: NotRequired[str]
249
+ rules: PolicyRules
250
+
251
+
252
+ class PolicyRuleEvaluation(TypedDict):
253
+ rule: str
254
+ status: str
255
+ message: str
256
+ expected: NotRequired[Any]
257
+ observedValue: NotRequired[Any]
258
+
259
+
260
+ class PolicyEvaluation(TypedDict):
261
+ did: str
262
+ policyName: NotRequired[str]
263
+ status: Literal["PASS", "FAIL", "INDETERMINATE", "NOT_APPLICABLE"]
264
+ headline: str
265
+ rulesEvaluated: int
266
+ rulesPassed: int
267
+ evaluations: List[PolicyRuleEvaluation]
268
+ evaluatedAt: str
269
+
270
+
271
+ class PolicyResponse(TypedDict):
272
+ evaluation: PolicyEvaluation
273
+ credential: Optional[CredentialVerification]
274
+ resolution: Optional[Dict[str, Any]]
275
+
276
+
277
+ class ToolEvidence(TypedDict):
278
+ name: str
279
+ title: NotRequired[str]
280
+ description: str
281
+ definitionSha256: str
282
+ schemaSha256: str
283
+ parameters: List[str]
284
+ annotations: Any
285
+ declaredClass: NotRequired[str]
286
+ heuristicClass: str
287
+ riskSignals: List[str]
288
+ drift: str
289
+ inputSchema: Any
290
+
291
+
292
+ class McpInspection(TypedDict):
293
+ endpoint: str
294
+ status: str
295
+ mode: str
296
+ negotiatedVersion: NotRequired[str]
297
+ supportedVersions: List[str]
298
+ serverInfo: Optional[Dict[str, Any]]
299
+ capabilities: Any
300
+ instructions: NotRequired[str]
301
+ tools: List[ToolEvidence]
302
+ inventoryHash: str
303
+ jcsProfile: NotRequired[str]
304
+ classCounts: Dict[str, int]
305
+ drift: Dict[str, Any]
306
+ auth: NotRequired[Dict[str, Any]]
307
+ transcript: List[Dict[str, Any]]
308
+ checks: List[Check]
309
+ headline: str
310
+ tls: NotRequired[Dict[str, Any]]
311
+ limitations: List[str]
312
+
313
+
314
+ class A2aInspection(TypedDict):
315
+ cardUrl: str
316
+ httpStatus: int
317
+ cardSha256: str
318
+ canonicalSha256: str
319
+ jcsProfile: NotRequired[str]
320
+ specVersion: str
321
+ name: str
322
+ description: str
323
+ version: NotRequired[str]
324
+ provider: NotRequired[Dict[str, Any]]
325
+ documentationUrl: NotRequired[str]
326
+ interfaces: List[Dict[str, Any]]
327
+ capabilities: Optional[Dict[str, Any]]
328
+ securitySchemes: List[Dict[str, Any]]
329
+ skills: List[Dict[str, Any]]
330
+ signatures: List[Dict[str, Any]]
331
+ checks: List[Check]
332
+ headline: str
333
+ card: Any
334
+ tls: NotRequired[Dict[str, Any]]
335
+ limitations: List[str]
336
+
337
+
338
+ class DelegationLink(TypedDict):
339
+ index: int
340
+ issuer: str
341
+ audience: str
342
+ capabilities: List[str]
343
+ notBefore: NotRequired[str]
344
+ expires: NotRequired[str]
345
+ jti: NotRequired[str]
346
+ kid: NotRequired[str]
347
+ alg: NotRequired[str]
348
+ digest: str
349
+ signatureValid: bool
350
+ status: str
351
+ issuerHeadline: NotRequired[str]
352
+ checks: List[Check]
353
+
354
+
355
+ class DelegationChainResult(TypedDict):
356
+ status: str
357
+ headline: str
358
+ format: str
359
+ root: NotRequired[str]
360
+ leaf: NotRequired[str]
361
+ effectiveCapabilities: List[str]
362
+ notAfter: NotRequired[str]
363
+ trustedRoot: NotRequired[bool]
364
+ links: List[DelegationLink]
365
+ checks: List[Check]
366
+ limitations: List[str]
367
+
368
+
369
+ class DelegationVerification(TypedDict):
370
+ result: DelegationChainResult
371
+ registered: bool
372
+
373
+
374
+ class ToolAuthorization(TypedDict):
375
+ agent: str
376
+ tool: str
377
+ decision: Literal["ALLOW", "DENY"]
378
+ authorized: bool
379
+ reason: str
380
+ matchedCapability: NotRequired[str]
381
+ root: NotRequired[str]
382
+ expires: NotRequired[str]
383
+
384
+
385
+ class AuthorizationResponse(TypedDict):
386
+ authorization: ToolAuthorization
387
+ chain: DelegationChainResult
388
+
389
+
390
+ class FastVerifyDidResult(TypedDict):
391
+ did: str
392
+ resolved: bool
393
+ outcome: str
394
+ headline: str
395
+ statements: List[str]
396
+ dimensions: List[Dict[str, str]]
397
+ keys: List[Dict[str, Any]]
398
+ domainBinding: Optional[str]
399
+ documentSha256: NotRequired[str]
400
+ observedAt: str
401
+ cached: bool
402
+
403
+
404
+ class FastVerifyJwsResult(TypedDict):
405
+ status: Literal["VALID", "INVALID", "INDETERMINATE", "UNSUPPORTED", "MALFORMED"]
406
+ valid: bool
407
+ headline: str
408
+ alg: NotRequired[str]
409
+ kid: NotRequired[str]
410
+ typ: NotRequired[str]
411
+ payload: NotRequired[Any]
412
+ signingInputSha256: NotRequired[str]
413
+ signer: NotRequired[str]
414
+ relationship: NotRequired[str]
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [tool.hatch.build.targets.wheel]
6
+ # The import package directory is `didis`, which differs from the distribution name `didis-py`,
7
+ # so it must be declared explicitly for hatchling to locate it.
8
+ packages = ["didis"]
9
+
10
+ [project]
11
+ name = "didis-py"
12
+ version = "0.1.0"
13
+ description = "Official Python SDK for the DID.is public API: DID resolution with evidence, credentials, policies and agent trust"
14
+ readme = "README.md"
15
+ requires-python = ">=3.11"
16
+ license = "MIT"
17
+ authors = [
18
+ { name = "DID.is" }
19
+ ]
20
+ dependencies = [
21
+ "httpx>=0.27.0",
22
+ ]
23
+ keywords = ["did", "w3c", "decentralized-identifiers", "verifiable-credentials", "mcp", "a2a", "resolver"]
24
+ classifiers = [
25
+ "Development Status :: 4 - Beta",
26
+ "Intended Audience :: Developers",
27
+ "License :: OSI Approved :: MIT License",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.11",
30
+ "Programming Language :: Python :: 3.12",
31
+ "Programming Language :: Python :: 3.13",
32
+ "Typing :: Typed",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://did.is"
37
+ Documentation = "https://did.is/developers"
38
+ Repository = "https://github.com/did-is/didis-py"
39
+ Issues = "https://github.com/did-is/didis-py/issues"
@@ -0,0 +1,53 @@
1
+ """Sync/async credential audience options preserve raw text and error retry metadata."""
2
+ import json
3
+ import unittest
4
+ from unittest.mock import patch
5
+
6
+ import httpx
7
+ from didis.client import AsyncDidisClient, DidisClient, DidisError
8
+
9
+ RAW = '{"issuer":"bad","issuer":"good"}'
10
+
11
+
12
+ def transport_handler(request: httpx.Request) -> httpx.Response:
13
+ assert request.url.path == "/v1/credentials/verify"
14
+ body = json.loads(request.content)
15
+ assert body["credential"] == RAW
16
+ if body.get("expectedAudience") == "recipient":
17
+ return httpx.Response(429, json={"title": "Limited", "detail": "Retry later"}, headers={"retry-after": "5"})
18
+ assert "expectedAudience" not in body
19
+ return httpx.Response(200, json={"status": "MALFORMED", "valid": False})
20
+
21
+
22
+ class SyncContract(unittest.TestCase):
23
+ def test_additive_audience_raw_text_and_retry_hint(self) -> None:
24
+ underlying = httpx.Client(base_url="https://example.com", transport=httpx.MockTransport(transport_handler))
25
+ with patch("didis.client.httpx.Client", return_value=underlying):
26
+ client = DidisClient("https://example.com")
27
+ try:
28
+ self.assertEqual(client.verify_credential(RAW)["status"], "MALFORMED")
29
+ with self.assertRaises(DidisError) as caught:
30
+ client.verify_credential(RAW, expected_audience="recipient")
31
+ self.assertEqual(caught.exception.status, 429)
32
+ self.assertEqual(caught.exception.problem["retryAfter"], "5")
33
+ finally:
34
+ client.close()
35
+
36
+
37
+ class AsyncContract(unittest.IsolatedAsyncioTestCase):
38
+ async def test_additive_audience_raw_text_and_retry_hint(self) -> None:
39
+ underlying = httpx.AsyncClient(base_url="https://example.com", transport=httpx.MockTransport(transport_handler))
40
+ with patch("didis.client.httpx.AsyncClient", return_value=underlying):
41
+ client = AsyncDidisClient("https://example.com")
42
+ try:
43
+ self.assertEqual((await client.verify_credential(RAW))["status"], "MALFORMED")
44
+ with self.assertRaises(DidisError) as caught:
45
+ await client.verify_credential(RAW, expected_audience="recipient")
46
+ self.assertEqual(caught.exception.status, 429)
47
+ self.assertEqual(caught.exception.problem["retryAfter"], "5")
48
+ finally:
49
+ await client.close()
50
+
51
+
52
+ if __name__ == "__main__":
53
+ unittest.main()
@@ -0,0 +1,45 @@
1
+ """Independent Python receiver verification, no provider or network access."""
2
+ import hashlib
3
+ import hmac
4
+ import json
5
+ import time
6
+ import unittest
7
+ from didis import verify_customer_monitor_signature
8
+
9
+
10
+ class CustomerSignatureTests(unittest.TestCase):
11
+ def setUp(self):
12
+ self.now = int(time.time())
13
+ self.secret = "synthetic-python-customer-webhook-secret"
14
+ self.scope = dict(tenant_id="tenant_" + "a" * 64, project_id="project_" + "b" * 64, event_id="cevent_" + "c" * 64)
15
+
16
+ def sample(self, timestamp=None):
17
+ timestamp = self.now if timestamp is None else timestamp
18
+ body = json.dumps(dict(specVersion="didis.customer-monitor.v1", id=self.scope["event_id"], tenantId=self.scope["tenant_id"], projectId=self.scope["project_id"], createdAt=timestamp, data={"note": "€ 😀"}), ensure_ascii=False).encode()
19
+ signature = f"t={timestamp},v1={hmac.new(self.secret.encode(), str(timestamp).encode() + b'.' + body, hashlib.sha256).hexdigest()}"
20
+ return body, signature
21
+
22
+ def test_exact_raw_body_and_explicit_owner_scope(self):
23
+ body, signature = self.sample()
24
+ self.assertTrue(verify_customer_monitor_signature(self.secret, body, signature, **self.scope))
25
+ self.assertFalse(verify_customer_monitor_signature(self.secret, body + b" ", signature, **self.scope))
26
+ wrong = dict(self.scope, project_id="project_" + "d" * 64)
27
+ self.assertFalse(verify_customer_monitor_signature(self.secret, body, signature, **wrong))
28
+
29
+ def test_immutable_retry_time_not_admin_five_minute_tolerance(self):
30
+ body, signature = self.sample(self.now - 3600)
31
+ self.assertTrue(verify_customer_monitor_signature(self.secret, body, signature, **self.scope))
32
+ for ts in [self.now - 30 * 86400, self.now + 120]:
33
+ body, signature = self.sample(ts)
34
+ self.assertFalse(verify_customer_monitor_signature(self.secret, body, signature, **self.scope))
35
+
36
+ def test_malformed_header_and_invalid_limits_are_refused(self):
37
+ body, signature = self.sample()
38
+ for header in [signature + ",v1=other", "t=0,v1=short", "", signature.replace("t=", "t=0")]:
39
+ self.assertFalse(verify_customer_monitor_signature(self.secret, body, header, **self.scope))
40
+ for age in [0, -1, 2592001, True]:
41
+ self.assertFalse(verify_customer_monitor_signature(self.secret, body, signature, max_age_seconds=age, **self.scope))
42
+
43
+
44
+ if __name__ == "__main__":
45
+ unittest.main()