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.
- didis_py-0.1.0/.github/workflows/ci.yml +15 -0
- didis_py-0.1.0/.github/workflows/publish.yml +27 -0
- didis_py-0.1.0/.gitignore +5 -0
- didis_py-0.1.0/LICENSE +21 -0
- didis_py-0.1.0/PKG-INFO +107 -0
- didis_py-0.1.0/README.md +83 -0
- didis_py-0.1.0/didis/__init__.py +22 -0
- didis_py-0.1.0/didis/client.py +338 -0
- didis_py-0.1.0/didis/py.typed +0 -0
- didis_py-0.1.0/didis/types.py +414 -0
- didis_py-0.1.0/pyproject.toml +39 -0
- didis_py-0.1.0/tests/test_credential_contract.py +53 -0
- didis_py-0.1.0/tests/test_customer_webhook_signature.py +45 -0
|
@@ -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
|
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.
|
didis_py-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
didis_py-0.1.0/README.md
ADDED
|
@@ -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()
|