sm-divergence 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sm_divergence/__init__.py +119 -0
- sm_divergence/__main__.py +3 -0
- sm_divergence/_signing.py +88 -0
- sm_divergence/cli.py +109 -0
- sm_divergence/detector.py +61 -0
- sm_divergence/discovery/__init__.py +32 -0
- sm_divergence/discovery/attestation.py +81 -0
- sm_divergence/discovery/client.py +73 -0
- sm_divergence/discovery/closure.py +15 -0
- sm_divergence/discovery/http_by_id.py +54 -0
- sm_divergence/discovery/index_v2.py +132 -0
- sm_divergence/discovery/views.py +32 -0
- sm_divergence/identity/__init__.py +54 -0
- sm_divergence/identity/closure.py +64 -0
- sm_divergence/identity/description_layer.py +164 -0
- sm_divergence/identity/didkey.py +46 -0
- sm_divergence/identity/didweb.py +98 -0
- sm_divergence/identity/selfdesc.py +202 -0
- sm_divergence/identity/universal.py +76 -0
- sm_divergence/identity/views.py +41 -0
- sm_divergence-0.7.0.dist-info/METADATA +273 -0
- sm_divergence-0.7.0.dist-info/RECORD +25 -0
- sm_divergence-0.7.0.dist-info/WHEEL +4 -0
- sm_divergence-0.7.0.dist-info/entry_points.txt +2 -0
- sm_divergence-0.7.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""sm-divergence — catch a cheating registry by corroboration.
|
|
2
|
+
|
|
3
|
+
A registry that agents discover each other through can lie by omission (hide a
|
|
4
|
+
record), by tampering (serve a false endpoint), or by equivocation (different
|
|
5
|
+
answers to different clients). No signature on a single record can prove what a
|
|
6
|
+
registry chose not to serve. With two or more registries, this library asks all
|
|
7
|
+
of them the same questions and reports any disagreement: omission, endpoint, or
|
|
8
|
+
key-identity (DID) divergence.
|
|
9
|
+
|
|
10
|
+
Layout:
|
|
11
|
+
- ``sm-resolver`` (dependency) — the source-agnostic kernel: ``Resolver[T]``,
|
|
12
|
+
the ``View`` contract, ``diff_views``, ``Corroborator``, ``Finding``.
|
|
13
|
+
- ``sm_divergence.discovery`` — the registry-integrity layer: ``RecordView``
|
|
14
|
+
plus the ``HttpByIdResolver`` (NEST) and ``NandaIndexV2Resolver`` adapters.
|
|
15
|
+
|
|
16
|
+
Quick start::
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
from sm_divergence import check_once
|
|
20
|
+
|
|
21
|
+
findings = asyncio.run(check_once(
|
|
22
|
+
["https://registry-a.example", "https://registry-b.example"],
|
|
23
|
+
["agent-1", "agent-2"],
|
|
24
|
+
))
|
|
25
|
+
for f in findings:
|
|
26
|
+
print(f.kind, f.agent_id, f.detail)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from sm_resolver import OMISSION, Corroborator, Finding, OnFinding, Resolver, Status, View, ViewT, diff_views
|
|
30
|
+
|
|
31
|
+
from .detector import DivergenceDetector, check_once
|
|
32
|
+
from .discovery import (
|
|
33
|
+
DEFAULT_PATH,
|
|
34
|
+
DID,
|
|
35
|
+
ENDPOINT,
|
|
36
|
+
HttpByIdResolver,
|
|
37
|
+
NandaIndexV2Resolver,
|
|
38
|
+
RecordAdapter,
|
|
39
|
+
RecordView,
|
|
40
|
+
binds_by_did,
|
|
41
|
+
default_adapter,
|
|
42
|
+
fetch_view,
|
|
43
|
+
identity_locator,
|
|
44
|
+
looks_absent,
|
|
45
|
+
signed_record_adapter,
|
|
46
|
+
verified_did,
|
|
47
|
+
)
|
|
48
|
+
from .identity import (
|
|
49
|
+
KEY,
|
|
50
|
+
AliasClosureResolver,
|
|
51
|
+
DescriptionReconciliation,
|
|
52
|
+
DidKeyResolver,
|
|
53
|
+
DidView,
|
|
54
|
+
DidWebResolver,
|
|
55
|
+
HttpDescriptionResolver,
|
|
56
|
+
SelfDescription,
|
|
57
|
+
UniversalResolverResolver,
|
|
58
|
+
binds_by_key,
|
|
59
|
+
build_self_description,
|
|
60
|
+
corroborate_descriptions,
|
|
61
|
+
did_web_url,
|
|
62
|
+
identity_did,
|
|
63
|
+
key_from_did_document,
|
|
64
|
+
key_from_did_key,
|
|
65
|
+
reconcile_descriptions,
|
|
66
|
+
signed_alias_for,
|
|
67
|
+
verified_descriptions,
|
|
68
|
+
verify_self_description,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
__version__ = "0.7.0"
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"DEFAULT_PATH",
|
|
75
|
+
"DID",
|
|
76
|
+
"ENDPOINT",
|
|
77
|
+
"KEY",
|
|
78
|
+
"OMISSION",
|
|
79
|
+
"AliasClosureResolver",
|
|
80
|
+
"Corroborator",
|
|
81
|
+
"DescriptionReconciliation",
|
|
82
|
+
"DidKeyResolver",
|
|
83
|
+
"DidView",
|
|
84
|
+
"DidWebResolver",
|
|
85
|
+
"DivergenceDetector",
|
|
86
|
+
"Finding",
|
|
87
|
+
"HttpByIdResolver",
|
|
88
|
+
"HttpDescriptionResolver",
|
|
89
|
+
"NandaIndexV2Resolver",
|
|
90
|
+
"OnFinding",
|
|
91
|
+
"RecordAdapter",
|
|
92
|
+
"RecordView",
|
|
93
|
+
"Resolver",
|
|
94
|
+
"SelfDescription",
|
|
95
|
+
"Status",
|
|
96
|
+
"UniversalResolverResolver",
|
|
97
|
+
"View",
|
|
98
|
+
"ViewT",
|
|
99
|
+
"binds_by_did",
|
|
100
|
+
"binds_by_key",
|
|
101
|
+
"build_self_description",
|
|
102
|
+
"check_once",
|
|
103
|
+
"corroborate_descriptions",
|
|
104
|
+
"default_adapter",
|
|
105
|
+
"diff_views",
|
|
106
|
+
"did_web_url",
|
|
107
|
+
"fetch_view",
|
|
108
|
+
"identity_did",
|
|
109
|
+
"identity_locator",
|
|
110
|
+
"key_from_did_document",
|
|
111
|
+
"key_from_did_key",
|
|
112
|
+
"looks_absent",
|
|
113
|
+
"reconcile_descriptions",
|
|
114
|
+
"signed_alias_for",
|
|
115
|
+
"signed_record_adapter",
|
|
116
|
+
"verified_descriptions",
|
|
117
|
+
"verified_did",
|
|
118
|
+
"verify_self_description",
|
|
119
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Shared Ed25519 / ``did:key`` / JCS primitives — the self-certifying substrate.
|
|
2
|
+
|
|
3
|
+
Used by the discovery layer's verified-DID adapter and the identity layer's
|
|
4
|
+
self-description. Behind the ``attestation`` extra (``cryptography``, ``base58``,
|
|
5
|
+
``jcs``); every function raises a clear error if the extra is absent, so the base
|
|
6
|
+
install still runs the crypto-free checks (omission, endpoint, multibase key).
|
|
7
|
+
|
|
8
|
+
A ``did:key`` *is* the public key, so everything here verifies offline — no
|
|
9
|
+
network, no external trust.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import base58
|
|
20
|
+
import jcs # type: ignore[import-untyped]
|
|
21
|
+
from cryptography.exceptions import InvalidSignature
|
|
22
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
23
|
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
24
|
+
|
|
25
|
+
_HAVE_CRYPTO = True
|
|
26
|
+
except ImportError: # pragma: no cover - exercised only without the extra
|
|
27
|
+
_HAVE_CRYPTO = False
|
|
28
|
+
|
|
29
|
+
_ED25519_MULTICODEC = b"\xed\x01"
|
|
30
|
+
_EXTRA = "requires the 'attestation' extra: pip install sm-divergence[attestation]"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _require() -> None:
|
|
34
|
+
if not _HAVE_CRYPTO:
|
|
35
|
+
raise RuntimeError(_EXTRA)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def canonical(obj: Mapping[str, Any]) -> bytes:
|
|
39
|
+
"""RFC 8785 (JCS) canonical bytes — byte-identical for signer and verifier."""
|
|
40
|
+
_require()
|
|
41
|
+
result: bytes = jcs.canonicalize(obj)
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def pubkey_from_did_key(did: str) -> bytes | None:
|
|
46
|
+
"""The 32-byte Ed25519 public key a ``did:key`` encodes, or None."""
|
|
47
|
+
_require()
|
|
48
|
+
if not did.startswith("did:key:z"):
|
|
49
|
+
return None
|
|
50
|
+
try:
|
|
51
|
+
raw = base58.b58decode(did[len("did:key:z") :])
|
|
52
|
+
except Exception:
|
|
53
|
+
return None
|
|
54
|
+
if len(raw) != 34 or raw[:2] != _ED25519_MULTICODEC:
|
|
55
|
+
return None
|
|
56
|
+
return bytes(raw[2:])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def did_key_from_pubkey(pubkey: bytes) -> str:
|
|
60
|
+
"""The ``did:key`` for an Ed25519 public key (multicodec + base58btc)."""
|
|
61
|
+
_require()
|
|
62
|
+
encoded: str = base58.b58encode(_ED25519_MULTICODEC + pubkey).decode()
|
|
63
|
+
return "did:key:z" + encoded
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def public_from_private(private_key: bytes) -> bytes:
|
|
67
|
+
"""The 32-byte Ed25519 public key for a 32-byte private key."""
|
|
68
|
+
_require()
|
|
69
|
+
pub: bytes = (
|
|
70
|
+
Ed25519PrivateKey.from_private_bytes(private_key).public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
|
71
|
+
)
|
|
72
|
+
return pub
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def sign(private_key: bytes, message: bytes) -> str:
|
|
76
|
+
"""Base64 Ed25519 signature over ``message`` with a 32-byte private key."""
|
|
77
|
+
_require()
|
|
78
|
+
return base64.b64encode(Ed25519PrivateKey.from_private_bytes(private_key).sign(message)).decode()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def verify(pubkey: bytes, message: bytes, sig_b64: str) -> bool:
|
|
82
|
+
"""True iff ``sig_b64`` is a valid Ed25519 signature of ``message``. Never raises."""
|
|
83
|
+
_require()
|
|
84
|
+
try:
|
|
85
|
+
Ed25519PublicKey.from_public_bytes(pubkey).verify(base64.b64decode(sig_b64), message)
|
|
86
|
+
return True
|
|
87
|
+
except (InvalidSignature, Exception):
|
|
88
|
+
return False
|
sm_divergence/cli.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""``python -m sm_divergence`` — one-shot divergence check for cron / CI.
|
|
2
|
+
|
|
3
|
+
python -m sm_divergence check \
|
|
4
|
+
--registry https://registry-a.example \
|
|
5
|
+
--registry https://registry-b.example \
|
|
6
|
+
--watch agent-1 --watch agent-2
|
|
7
|
+
|
|
8
|
+
Exit code is 0 when the registries agree, 2 when any divergence is found (so a
|
|
9
|
+
cron or CI step fails loudly), and 1 on a usage error.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
|
|
20
|
+
from sm_resolver import Finding, Resolver
|
|
21
|
+
|
|
22
|
+
from .detector import check_once
|
|
23
|
+
from .discovery import DEFAULT_PATH, NandaIndexV2Resolver, RecordAdapter, RecordView, default_adapter
|
|
24
|
+
|
|
25
|
+
EXIT_OK = 0
|
|
26
|
+
EXIT_USAGE = 1
|
|
27
|
+
EXIT_DIVERGENCE = 2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
31
|
+
parser = argparse.ArgumentParser(prog="sm_divergence", description="Catch a cheating registry by corroboration.")
|
|
32
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
33
|
+
|
|
34
|
+
check = sub.add_parser("check", help="Compare registries for a set of ids.")
|
|
35
|
+
check.add_argument(
|
|
36
|
+
"--registry",
|
|
37
|
+
action="append",
|
|
38
|
+
default=[],
|
|
39
|
+
metavar="URL",
|
|
40
|
+
help="NEST-style by-id registry base URL (repeatable).",
|
|
41
|
+
)
|
|
42
|
+
check.add_argument(
|
|
43
|
+
"--nanda-index",
|
|
44
|
+
action="append",
|
|
45
|
+
default=[],
|
|
46
|
+
metavar="URL",
|
|
47
|
+
help="NANDA Index v2 base URL — two-hop resolve (repeatable). Watch ids are used as locators.",
|
|
48
|
+
)
|
|
49
|
+
check.add_argument("--watch", action="append", default=[], metavar="ID", help="Agent id to compare (repeatable).")
|
|
50
|
+
check.add_argument(
|
|
51
|
+
"--path", default=DEFAULT_PATH, help=f"By-id path template for --registry (default: {DEFAULT_PATH})."
|
|
52
|
+
)
|
|
53
|
+
check.add_argument("--timeout", type=float, default=10.0, help="Per-request timeout seconds (default: 10).")
|
|
54
|
+
check.add_argument(
|
|
55
|
+
"--attestation",
|
|
56
|
+
action="store_true",
|
|
57
|
+
help="Also compare verified DIDs from signed records (needs the 'attestation' extra).",
|
|
58
|
+
)
|
|
59
|
+
check.add_argument("--json", action="store_true", help="Emit findings as JSON.")
|
|
60
|
+
return parser
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _format_text(findings: list[Finding]) -> str:
|
|
64
|
+
if not findings:
|
|
65
|
+
return "OK — registries agree on all watched ids."
|
|
66
|
+
lines = [f"DIVERGENCE — {len(findings)} finding(s):"]
|
|
67
|
+
for f in findings:
|
|
68
|
+
lines.append(f" [{f.kind}] {f.agent_id}: {json.dumps(f.detail, sort_keys=True)}")
|
|
69
|
+
return "\n".join(lines)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def _run_check(args: argparse.Namespace) -> int:
|
|
73
|
+
watch = [w for w in args.watch if w]
|
|
74
|
+
http_registries = [r for r in args.registry if r]
|
|
75
|
+
index_urls = [u for u in args.nanda_index if u]
|
|
76
|
+
if len(http_registries) + len(index_urls) < 2:
|
|
77
|
+
print("error: need at least two registries (--registry / --nanda-index) to corroborate", file=sys.stderr)
|
|
78
|
+
return EXIT_USAGE
|
|
79
|
+
if not watch:
|
|
80
|
+
print("error: need at least one --watch id", file=sys.stderr)
|
|
81
|
+
return EXIT_USAGE
|
|
82
|
+
|
|
83
|
+
adapter: RecordAdapter = default_adapter
|
|
84
|
+
if args.attestation:
|
|
85
|
+
from .discovery import signed_record_adapter
|
|
86
|
+
|
|
87
|
+
adapter = signed_record_adapter()
|
|
88
|
+
|
|
89
|
+
registries: list[str | Resolver[RecordView]] = list(http_registries)
|
|
90
|
+
registries += [NandaIndexV2Resolver(u, adapter=adapter, timeout=args.timeout) for u in index_urls]
|
|
91
|
+
|
|
92
|
+
findings = await check_once(registries, watch, adapter=adapter, path=args.path, timeout=args.timeout)
|
|
93
|
+
|
|
94
|
+
if args.json:
|
|
95
|
+
print(json.dumps([f.to_dict() for f in findings], sort_keys=True, indent=2))
|
|
96
|
+
else:
|
|
97
|
+
print(_format_text(findings))
|
|
98
|
+
return EXIT_DIVERGENCE if findings else EXIT_OK
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
102
|
+
args = _build_parser().parse_args(argv)
|
|
103
|
+
if args.command == "check":
|
|
104
|
+
return asyncio.run(_run_check(args))
|
|
105
|
+
return EXIT_USAGE
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""The discovery-layer product: a ``Corroborator`` specialized to registries.
|
|
2
|
+
|
|
3
|
+
``DivergenceDetector`` is a thin convenience over the core ``Corroborator`` — it
|
|
4
|
+
accepts bare registry URLs (wrapping them in ``HttpByIdResolver``) alongside
|
|
5
|
+
``Resolver`` instances of any format, so ``check_once(["https://a", "https://b"])``
|
|
6
|
+
keeps working while heterogeneous resolvers mix freely. The generic orchestration
|
|
7
|
+
lives in ``sm-resolver`` (the kernel); this file only adds the discovery defaults.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
|
|
14
|
+
from sm_resolver import Corroborator, Finding, OnFinding, Resolver
|
|
15
|
+
|
|
16
|
+
from .discovery import DEFAULT_PATH, HttpByIdResolver, RecordAdapter, RecordView, default_adapter
|
|
17
|
+
|
|
18
|
+
__all__ = ["DivergenceDetector", "OnFinding", "check_once"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DivergenceDetector(Corroborator[RecordView]):
|
|
22
|
+
"""Cross-registry divergence detector.
|
|
23
|
+
|
|
24
|
+
Point it at two or more registries — each a base URL (wrapped in an
|
|
25
|
+
``HttpByIdResolver`` with the shared ``adapter``/``path``) or a ``Resolver``
|
|
26
|
+
instance for a registry of a different format (e.g. ``NandaIndexV2Resolver``).
|
|
27
|
+
Registries of different formats mix freely; each normalizes to a
|
|
28
|
+
``RecordView`` so the diff treats them uniformly. Fewer than two → no-op.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
registries: Iterable[str | Resolver[RecordView]],
|
|
34
|
+
*,
|
|
35
|
+
adapter: RecordAdapter = default_adapter,
|
|
36
|
+
path: str = DEFAULT_PATH,
|
|
37
|
+
on_finding: OnFinding | None = None,
|
|
38
|
+
timeout: float = 10.0,
|
|
39
|
+
) -> None:
|
|
40
|
+
resolvers: list[Resolver[RecordView]] = [
|
|
41
|
+
HttpByIdResolver(r, adapter=adapter, path=path, timeout=timeout) if isinstance(r, str) else r
|
|
42
|
+
for r in registries
|
|
43
|
+
]
|
|
44
|
+
super().__init__(resolvers, on_finding=on_finding)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def registries(self) -> list[str]:
|
|
48
|
+
"""The registry labels being compared (back-compat + introspection)."""
|
|
49
|
+
return self.labels
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def check_once(
|
|
53
|
+
registries: Iterable[str | Resolver[RecordView]],
|
|
54
|
+
watch_ids: Iterable[str],
|
|
55
|
+
*,
|
|
56
|
+
adapter: RecordAdapter = default_adapter,
|
|
57
|
+
path: str = DEFAULT_PATH,
|
|
58
|
+
timeout: float = 10.0,
|
|
59
|
+
) -> list[Finding]:
|
|
60
|
+
"""One-shot convenience: build a detector and run a single check."""
|
|
61
|
+
return await DivergenceDetector(registries, adapter=adapter, path=path, timeout=timeout).check(watch_ids)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""sm-divergence discovery layer — the registry-integrity instantiation.
|
|
2
|
+
|
|
3
|
+
Supplies the discovery view type (``RecordView``) and the format resolvers
|
|
4
|
+
(``HttpByIdResolver`` for NEST-style by-id registries, ``NandaIndexV2Resolver``
|
|
5
|
+
for the NANDA Index two-hop resolve), all built on the source-agnostic core.
|
|
6
|
+
Each resolver is a thin adapter; adding another registry format is another file
|
|
7
|
+
here, with no change to the core diff.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .attestation import signed_record_adapter, verified_did
|
|
11
|
+
from .client import DEFAULT_PATH, RecordAdapter, default_adapter, fetch_view, looks_absent
|
|
12
|
+
from .closure import binds_by_did
|
|
13
|
+
from .http_by_id import HttpByIdResolver
|
|
14
|
+
from .index_v2 import NandaIndexV2Resolver, identity_locator
|
|
15
|
+
from .views import DID, ENDPOINT, RecordView
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DEFAULT_PATH",
|
|
19
|
+
"DID",
|
|
20
|
+
"ENDPOINT",
|
|
21
|
+
"HttpByIdResolver",
|
|
22
|
+
"NandaIndexV2Resolver",
|
|
23
|
+
"RecordAdapter",
|
|
24
|
+
"RecordView",
|
|
25
|
+
"binds_by_did",
|
|
26
|
+
"default_adapter",
|
|
27
|
+
"fetch_view",
|
|
28
|
+
"identity_locator",
|
|
29
|
+
"looks_absent",
|
|
30
|
+
"signed_record_adapter",
|
|
31
|
+
"verified_did",
|
|
32
|
+
]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Optional: a record adapter that extracts a *verified* DID.
|
|
2
|
+
|
|
3
|
+
Endpoint and omission divergence need no crypto. The ``did`` divergence — two
|
|
4
|
+
registries serving records that claim different key identities — only means
|
|
5
|
+
something if each DID was actually proven, so this module verifies a signed,
|
|
6
|
+
self-certifying record before contributing its DID to the comparison.
|
|
7
|
+
|
|
8
|
+
Reference format (a common self-certifying-record convention, not tied to any
|
|
9
|
+
one registry):
|
|
10
|
+
|
|
11
|
+
{
|
|
12
|
+
"record": { ... , "did": "did:key:z6Mk...", "endpoint": "https://..." },
|
|
13
|
+
"sig": "<base64 Ed25519 signature over the JCS-canonical record>"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
The DID is verified against ITSELF: a ``did:key`` encodes the Ed25519 public
|
|
17
|
+
key, so the signature is checked with no external lookup. A record whose
|
|
18
|
+
signature doesn't verify (or whose subject doesn't match) contributes no DID.
|
|
19
|
+
|
|
20
|
+
Requires the ``attestation`` extra: ``pip install sm-divergence[attestation]``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from .. import _signing
|
|
28
|
+
from .client import RecordAdapter
|
|
29
|
+
from .views import RecordView
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def verified_did(attestation: Any) -> str | None:
|
|
33
|
+
"""The DID of a signed record iff its signature verifies against the key
|
|
34
|
+
the DID itself encodes; else None. Never raises."""
|
|
35
|
+
if not isinstance(attestation, dict):
|
|
36
|
+
return None
|
|
37
|
+
record = attestation.get("record")
|
|
38
|
+
sig_b64 = attestation.get("sig")
|
|
39
|
+
if not isinstance(record, dict) or not isinstance(sig_b64, str) or not sig_b64:
|
|
40
|
+
return None
|
|
41
|
+
did = record.get("did")
|
|
42
|
+
if not isinstance(did, str):
|
|
43
|
+
return None
|
|
44
|
+
pub = _signing.pubkey_from_did_key(did)
|
|
45
|
+
if pub is None:
|
|
46
|
+
return None
|
|
47
|
+
if not _signing.verify(pub, _signing.canonical(record), sig_b64):
|
|
48
|
+
return None
|
|
49
|
+
return did
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def signed_record_adapter(
|
|
53
|
+
endpoint_field: str = "endpoint",
|
|
54
|
+
attestation_field: str = "attestation",
|
|
55
|
+
*,
|
|
56
|
+
require_subject_match: bool = True,
|
|
57
|
+
) -> RecordAdapter:
|
|
58
|
+
"""A ``RecordAdapter`` that maps a registry record to endpoint + VERIFIED DID.
|
|
59
|
+
|
|
60
|
+
The DID is taken only from a valid attestation under ``attestation_field``.
|
|
61
|
+
When ``require_subject_match`` is set (default), the attestation's
|
|
62
|
+
``record.agent_id`` must equal the outer record's — a registry can't dress
|
|
63
|
+
one agent's record in another's valid attestation.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def _adapt(record: dict[str, Any]) -> RecordView:
|
|
67
|
+
ep = record.get(endpoint_field)
|
|
68
|
+
endpoint = str(ep).rstrip("/") if isinstance(ep, str) and ep else None
|
|
69
|
+
|
|
70
|
+
did: str | None = None
|
|
71
|
+
att = record.get(attestation_field)
|
|
72
|
+
vdid = verified_did(att) if att is not None else None
|
|
73
|
+
if vdid is not None:
|
|
74
|
+
inner = att.get("record", {}) if isinstance(att, dict) else {}
|
|
75
|
+
subject = inner.get("agent_id")
|
|
76
|
+
outer = record.get("agent_id")
|
|
77
|
+
if not require_subject_match or subject is None or outer is None or str(subject) == str(outer):
|
|
78
|
+
did = vdid
|
|
79
|
+
return RecordView(endpoint=endpoint, did=did)
|
|
80
|
+
|
|
81
|
+
return _adapt
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Fetch one registry's claim about one id over HTTP, reduced to a RecordView.
|
|
2
|
+
|
|
3
|
+
The HTTP path and the record→RecordView mapping are both injectable, so this
|
|
4
|
+
works against any registry that answers a by-id query. The default targets the
|
|
5
|
+
common NEST-style ``GET /api/agents/{id}`` returning JSON with a top-level
|
|
6
|
+
``endpoint``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from sm_resolver import Status
|
|
16
|
+
|
|
17
|
+
from .views import RecordView
|
|
18
|
+
|
|
19
|
+
# Maps a raw registry record (parsed JSON dict) to the discovery view.
|
|
20
|
+
RecordAdapter = Callable[[dict[str, Any]], RecordView]
|
|
21
|
+
|
|
22
|
+
DEFAULT_PATH = "/api/agents/{id}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def default_adapter(record: dict[str, Any]) -> RecordView:
|
|
26
|
+
"""Read a top-level ``endpoint`` string; no DID (see
|
|
27
|
+
``sm_divergence.discovery.attestation`` for a signed-record adapter)."""
|
|
28
|
+
ep = record.get("endpoint")
|
|
29
|
+
return RecordView(endpoint=str(ep).rstrip("/") if isinstance(ep, str) and ep else None)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def looks_absent(doc: dict[str, Any]) -> bool:
|
|
33
|
+
"""A 200 body that actually means 'not found' — some registries soft-404
|
|
34
|
+
with ``{"success": false, "error": "... not found"}`` instead of a 404.
|
|
35
|
+
Shared by every HTTP resolver so soft-404 is classified consistently."""
|
|
36
|
+
if doc.get("success") is False:
|
|
37
|
+
return True
|
|
38
|
+
return "not found" in str(doc.get("error") or "").lower()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def fetch_view(
|
|
42
|
+
client: httpx.AsyncClient,
|
|
43
|
+
registry_url: str,
|
|
44
|
+
agent_id: str,
|
|
45
|
+
*,
|
|
46
|
+
adapter: RecordAdapter = default_adapter,
|
|
47
|
+
path: str = DEFAULT_PATH,
|
|
48
|
+
timeout: float = 10.0,
|
|
49
|
+
) -> tuple[Status, RecordView | None]:
|
|
50
|
+
"""One registry's claim about one id (``present`` / ``absent`` / ``error``).
|
|
51
|
+
Never raises."""
|
|
52
|
+
url = registry_url.rstrip("/") + path.replace("{id}", agent_id)
|
|
53
|
+
try:
|
|
54
|
+
resp = await client.get(url, timeout=timeout)
|
|
55
|
+
except Exception:
|
|
56
|
+
return "error", None
|
|
57
|
+
if resp.status_code == 404:
|
|
58
|
+
return "absent", None
|
|
59
|
+
if resp.status_code != 200:
|
|
60
|
+
return "error", None
|
|
61
|
+
try:
|
|
62
|
+
doc = resp.json()
|
|
63
|
+
except Exception:
|
|
64
|
+
return "error", None
|
|
65
|
+
if not isinstance(doc, dict):
|
|
66
|
+
return "error", None
|
|
67
|
+
if looks_absent(doc):
|
|
68
|
+
return "absent", None
|
|
69
|
+
try:
|
|
70
|
+
return "present", adapter(doc)
|
|
71
|
+
except Exception:
|
|
72
|
+
# A malformed record the adapter can't map is not a claim of absence.
|
|
73
|
+
return "error", None
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Discovery bind-back predicate for alias closure (see
|
|
2
|
+
``sm_divergence.identity.AliasClosureResolver``).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from .views import RecordView
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def binds_by_did(view: RecordView, expected_did: str) -> bool:
|
|
11
|
+
"""Discovery bind-back: the record's VERIFIED did must equal the expected
|
|
12
|
+
``did:key``. A record with no verified did (no valid attestation) cannot bind
|
|
13
|
+
back, so it contributes no claim — you cannot safely follow a claimed
|
|
14
|
+
discovery alias without a record that proves it is the same agent."""
|
|
15
|
+
return view.did is not None and view.did == expected_did
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""HttpByIdResolver — the ``GET {base}/api/agents/{id}`` registry shape (e.g. NEST).
|
|
2
|
+
|
|
3
|
+
The reference discovery resolver: a thin adapter mapping a single-hop by-id HTTP
|
|
4
|
+
registry onto the core ``Resolver`` protocol.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from sm_resolver import Status
|
|
11
|
+
|
|
12
|
+
from .client import DEFAULT_PATH, RecordAdapter, default_adapter, fetch_view
|
|
13
|
+
from .views import RecordView
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HttpByIdResolver:
|
|
17
|
+
"""A registry that serves ``GET {base}/api/agents/{id}``.
|
|
18
|
+
|
|
19
|
+
The record path and the record→view mapping are injectable, so this also
|
|
20
|
+
covers any single-hop by-id HTTP registry. ``transport`` is an injectable
|
|
21
|
+
httpx transport for tests; ``None`` uses the real network.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
base_url: str,
|
|
27
|
+
*,
|
|
28
|
+
adapter: RecordAdapter = default_adapter,
|
|
29
|
+
path: str = DEFAULT_PATH,
|
|
30
|
+
timeout: float = 10.0,
|
|
31
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
32
|
+
label: str | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.base_url = base_url.rstrip("/")
|
|
35
|
+
self.adapter = adapter
|
|
36
|
+
self.path = path
|
|
37
|
+
self.timeout = timeout
|
|
38
|
+
self._transport = transport
|
|
39
|
+
self._label = label or self.base_url
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def label(self) -> str:
|
|
43
|
+
return self._label
|
|
44
|
+
|
|
45
|
+
async def resolve(self, agent_id: str) -> tuple[Status, RecordView | None]:
|
|
46
|
+
async with httpx.AsyncClient(transport=self._transport) as client:
|
|
47
|
+
return await fetch_view(
|
|
48
|
+
client,
|
|
49
|
+
self.base_url,
|
|
50
|
+
agent_id,
|
|
51
|
+
adapter=self.adapter,
|
|
52
|
+
path=self.path,
|
|
53
|
+
timeout=self.timeout,
|
|
54
|
+
)
|