openentity 0.5.4__tar.gz → 0.6.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.
- {openentity-0.5.4 → openentity-0.6.0}/PKG-INFO +2 -2
- {openentity-0.5.4 → openentity-0.6.0}/README.md +1 -1
- openentity-0.6.0/openentity/__init__.py +27 -0
- {openentity-0.5.4 → openentity-0.6.0}/openentity/cli.py +17 -17
- {openentity-0.5.4 → openentity-0.6.0}/openentity/client.py +12 -17
- {openentity-0.5.4 → openentity-0.6.0}/openentity/core.py +91 -31
- openentity-0.6.0/openentity/errors.py +51 -0
- {openentity-0.5.4 → openentity-0.6.0}/openentity/httpsig.py +4 -9
- {openentity-0.5.4 → openentity-0.6.0}/openentity/issuer.py +12 -7
- {openentity-0.5.4 → openentity-0.6.0}/openentity/report.py +13 -8
- {openentity-0.5.4 → openentity-0.6.0}/pyproject.toml +1 -1
- openentity-0.6.0/tests/test_api.py +79 -0
- {openentity-0.5.4 → openentity-0.6.0}/uv.lock +1 -1
- openentity-0.5.4/openentity/__init__.py +0 -8
- {openentity-0.5.4 → openentity-0.6.0}/.gitignore +0 -0
- {openentity-0.5.4 → openentity-0.6.0}/openentity/identity.py +0 -0
- {openentity-0.5.4 → openentity-0.6.0}/openentity/schema.py +0 -0
- {openentity-0.5.4 → openentity-0.6.0}/openentity/schemas/openentity-1.0.json +0 -0
- {openentity-0.5.4 → openentity-0.6.0}/tests/test_cli.py +0 -0
- {openentity-0.5.4 → openentity-0.6.0}/tests/test_client.py +0 -0
- {openentity-0.5.4 → openentity-0.6.0}/tests/test_core.py +0 -0
- {openentity-0.5.4 → openentity-0.6.0}/tests/test_httpsig.py +0 -0
- {openentity-0.5.4 → openentity-0.6.0}/tests/test_identity.py +0 -0
- {openentity-0.5.4 → openentity-0.6.0}/tests/test_issuer.py +0 -0
- {openentity-0.5.4 → openentity-0.6.0}/tests/test_parity.py +0 -0
- {openentity-0.5.4 → openentity-0.6.0}/tests/test_trust.py +0 -0
- {openentity-0.5.4 → openentity-0.6.0}/tests/test_verifier.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: openentity
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Open Entity (OEIS-1) client: JCS, did:key, eddsa-jcs-2022, Merkle proofs, adopt().
|
|
5
5
|
Project-URL: Homepage, https://openentity.org
|
|
6
6
|
Project-URL: Documentation, https://oeis.dev
|
|
@@ -53,7 +53,7 @@ Exit codes match the npm CLI: 0 ok, 1 usage, 2 network, 3 verification failed, 4
|
|
|
53
53
|
|
|
54
54
|
Library, each taking the 32-byte seed and a registry URL: `adopt(name, registry=...)`, `withdraw(seed, registry)`, `record(seed, [(kind, payload), ...], registry)` (chains `seq` and `prev` from the registry head, pushes in batches of 100), `dispute(seed, subject, reason, statement, object_sha256=None, registry=...)`, `find(registry, capability=..., q=..., alias_type=..., level=...)`, `present(seed, audience, nonce, registry=None)`. Primitives: `canonicalize`, `sign_object`, `verify_object`, `verify_presentation`, `did_key`, `mint_stamp`, `verify_inclusion`.
|
|
55
55
|
|
|
56
|
-
Shared core: `Identity.load_or_create()` (key in the OS keychain, else `~/.openentity/identity.key` at 0600; pass `.seed` to the calls above), `adopt(..., client="adk")` (an unsigned label: adk, goose, langchain, ui-tars, cli, other), `trust(eid)` (a `TrustReport` from registry reads only, every signature verified locally, cached 5 minutes, `ok=False` on any failure), and `openentity.issuer` (`attestation`, `revocation`, `did_document`, `openentity_json`).
|
|
56
|
+
Shared core: `Identity.load_or_create()` (key in the OS keychain, else `~/.openentity/identity.key` at 0600; pass `.seed` to the calls above), `adopt(..., client="adk")` (an unsigned label: adk, crewai, goose, langchain, mastra, openai-agents, ui-tars, vercel-ai, cli, other), `trust(eid)` (a `TrustReport` from registry reads only, every signature verified locally, cached 5 minutes, `ok=False` on any failure), and `openentity.issuer` (`attestation`, `revocation`, `did_document`, `openentity_json`).
|
|
57
57
|
|
|
58
58
|
HTTP message signatures (RFC 9421, Web Bot Auth profile): `sign_request(method, url, body, private_key, did)` returns `Signature-Input`, `Signature` and, for a non-empty body, `Content-Digest` (`private_key` is an `Identity`, a `KeyPair` or a 32-byte seed; ed25519, `keyid` = the signer's did:key, covers `@method`, `@target-uri`, `content-digest`; `created`, `expires`, `nonce`). `verify_request(method, url, headers, body)` returns `{"did", "keyid", "created", "expires", "nonce"}` or raises `HttpSigError` with a `.code` (`missing_headers`, `unknown_alg`, `bad_keyid`, `digest_mismatch`, `created_in_future`, `created_too_old`, `expired`, `lifetime_too_long`, `missing_nonce`, `bad_signature`). Nonce replay tracking is yours. Shared vectors: `test-vectors/http-signature.json`.
|
|
59
59
|
|
|
@@ -37,7 +37,7 @@ Exit codes match the npm CLI: 0 ok, 1 usage, 2 network, 3 verification failed, 4
|
|
|
37
37
|
|
|
38
38
|
Library, each taking the 32-byte seed and a registry URL: `adopt(name, registry=...)`, `withdraw(seed, registry)`, `record(seed, [(kind, payload), ...], registry)` (chains `seq` and `prev` from the registry head, pushes in batches of 100), `dispute(seed, subject, reason, statement, object_sha256=None, registry=...)`, `find(registry, capability=..., q=..., alias_type=..., level=...)`, `present(seed, audience, nonce, registry=None)`. Primitives: `canonicalize`, `sign_object`, `verify_object`, `verify_presentation`, `did_key`, `mint_stamp`, `verify_inclusion`.
|
|
39
39
|
|
|
40
|
-
Shared core: `Identity.load_or_create()` (key in the OS keychain, else `~/.openentity/identity.key` at 0600; pass `.seed` to the calls above), `adopt(..., client="adk")` (an unsigned label: adk, goose, langchain, ui-tars, cli, other), `trust(eid)` (a `TrustReport` from registry reads only, every signature verified locally, cached 5 minutes, `ok=False` on any failure), and `openentity.issuer` (`attestation`, `revocation`, `did_document`, `openentity_json`).
|
|
40
|
+
Shared core: `Identity.load_or_create()` (key in the OS keychain, else `~/.openentity/identity.key` at 0600; pass `.seed` to the calls above), `adopt(..., client="adk")` (an unsigned label: adk, crewai, goose, langchain, mastra, openai-agents, ui-tars, vercel-ai, cli, other), `trust(eid)` (a `TrustReport` from registry reads only, every signature verified locally, cached 5 minutes, `ok=False` on any failure), and `openentity.issuer` (`attestation`, `revocation`, `did_document`, `openentity_json`).
|
|
41
41
|
|
|
42
42
|
HTTP message signatures (RFC 9421, Web Bot Auth profile): `sign_request(method, url, body, private_key, did)` returns `Signature-Input`, `Signature` and, for a non-empty body, `Content-Digest` (`private_key` is an `Identity`, a `KeyPair` or a 32-byte seed; ed25519, `keyid` = the signer's did:key, covers `@method`, `@target-uri`, `content-digest`; `created`, `expires`, `nonce`). `verify_request(method, url, headers, body)` returns `{"did", "keyid", "created", "expires", "nonce"}` or raises `HttpSigError` with a `.code` (`missing_headers`, `unknown_alg`, `bad_keyid`, `digest_mismatch`, `created_in_future`, `created_too_old`, `expired`, `lifetime_too_long`, `missing_nonce`, `bad_signature`). Nonce replay tracking is yours. Shared vectors: `test-vectors/http-signature.json`.
|
|
43
43
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""The Open Entity SDK for Python. The public surface is `__all__`; tests/test_api.py pins it.
|
|
2
|
+
|
|
3
|
+
The names mirror the TypeScript SDK (@openentity/sdk) with snake_case for camelCase. Functions that exist in only one SDK are
|
|
4
|
+
listed in tests/test_api.py. The 0.5 names that were renamed stay importable (deprecated, removed in 1.0)."""
|
|
5
|
+
from . import issuer # noqa: F401
|
|
6
|
+
from .client import CLIENTS, DEFAULT_REGISTRY, DISPUTE_REASONS, adopt, dispute, find, present, record, rotate, withdraw
|
|
7
|
+
from .core import * # noqa: F401,F403
|
|
8
|
+
from .core import __all__ as _core
|
|
9
|
+
from .core import b58decode, b58encode, did_key, nonce, now, public_from_did_key, verification_method # noqa: F401 (deprecated)
|
|
10
|
+
from .errors import (REGISTRY_ERROR_CODES, HttpSigError, InvalidInputError, OpenEntityError, RegistryError, # noqa: F401
|
|
11
|
+
VerificationError)
|
|
12
|
+
from .httpsig import sign_request, verify_request
|
|
13
|
+
from .identity import Identity
|
|
14
|
+
from .issuer import (A2A_EXTENSION_URI, a2a_extension, agent_card, build_did_document, build_openentity_json, sign_attestation,
|
|
15
|
+
sign_revocation)
|
|
16
|
+
from .report import TrustReport, clear_trust_cache, did_web_resolver, trust
|
|
17
|
+
from .schema import validate
|
|
18
|
+
|
|
19
|
+
__version__ = "0.6.0"
|
|
20
|
+
|
|
21
|
+
__all__ = sorted([
|
|
22
|
+
*_core, "CLIENTS", "DEFAULT_REGISTRY", "DISPUTE_REASONS", "adopt", "dispute", "find", "present", "record", "rotate", "withdraw",
|
|
23
|
+
"REGISTRY_ERROR_CODES", "HttpSigError", "InvalidInputError", "OpenEntityError", "RegistryError", "VerificationError",
|
|
24
|
+
"sign_request", "verify_request", "Identity", "A2A_EXTENSION_URI", "a2a_extension", "agent_card", "build_did_document",
|
|
25
|
+
"build_openentity_json", "sign_attestation", "sign_revocation", "TrustReport", "clear_trust_cache", "did_web_resolver", "trust",
|
|
26
|
+
"validate",
|
|
27
|
+
])
|
|
@@ -205,7 +205,7 @@ def _read_record(d: pathlib.Path) -> list[dict]:
|
|
|
205
205
|
|
|
206
206
|
def _append_entry(e: Entity, kind: str, payload: dict) -> dict:
|
|
207
207
|
last = (_read_record(e.dir) or [None])[-1]
|
|
208
|
-
at = core.
|
|
208
|
+
at = core.now_seconds()
|
|
209
209
|
entry = core.sign_object({"spec": core.SPEC, "type": "RecordEntry", "eid": e.eid, "seq": last["seq"] + 1 if last else 1,
|
|
210
210
|
"prev": core.canonical_hash(last).hex() if last else None, "kind": kind, "at": at, "payload": payload}, e.kp, at)
|
|
211
211
|
if len(json.dumps(entry)) > 16 * 1024:
|
|
@@ -340,7 +340,7 @@ def _adopt(args):
|
|
|
340
340
|
if theirs != core.DECLARATION_SHA256:
|
|
341
341
|
raise CliError(EXIT_VERIFY, f"refusing to sign: registry declaration hash {theirs or '(missing)'} differs from this CLI's {core.DECLARATION_SHA256}. Upgrade the CLI or check --registry.")
|
|
342
342
|
kp = core.KeyPair()
|
|
343
|
-
created = core.
|
|
343
|
+
created = core.now_seconds()
|
|
344
344
|
op_name = args.operator or os.environ.get("OPENENTITY_OPERATOR")
|
|
345
345
|
operator = {"kind": args.operator_kind or ("human" if op_name else "none"), **({"name": op_name} if op_name else {}),
|
|
346
346
|
**({"contact": args.contact} if args.contact else {}), "custody": args.custody or "entity", "can_change": []}
|
|
@@ -439,7 +439,7 @@ def _withdraw(args):
|
|
|
439
439
|
raise _usage("withdraw marks this entity withdrawn in the append-only log and cannot be undone; re-run with --force")
|
|
440
440
|
e = _load(args)
|
|
441
441
|
w = core.sign_object({"spec": core.SPEC, "type": "Withdrawal", "eid": e.eid, "declaration_version": (e.declaration or {}).get("declaration_version", core.DECLARATION_VERSION),
|
|
442
|
-
"name": e.doc.get("name"), "created": core.
|
|
442
|
+
"name": e.doc.get("name"), "created": core.now_seconds(), "nonce": core.random_nonce()}, e.kp)
|
|
443
443
|
res = e.reg.send("POST", "/v1/withdraw", w, e.eid)
|
|
444
444
|
_write_json(e.dir / "withdrawal.json", w)
|
|
445
445
|
return res, f"withdrawn: {e.eid} (log index {res.get('log_index')})"
|
|
@@ -550,7 +550,7 @@ def _conformance(args):
|
|
|
550
550
|
|
|
551
551
|
def checkpoint_signature():
|
|
552
552
|
_must(cp["ok"], cp["detail"])
|
|
553
|
-
pk = core.
|
|
553
|
+
pk = core.base58btc_decode(spec["registry"]["publicKeyMultibase"][1:])[-32:]
|
|
554
554
|
note = _probe(base, "/v1/log/checkpoint")[3].decode("utf8")
|
|
555
555
|
_must(core.verify_note(note, spec["registry"]["log_origin"], pk), "GET /v1/log/checkpoint does not verify against the published key")
|
|
556
556
|
return cp["detail"]
|
|
@@ -727,7 +727,7 @@ def _push_draft(e):
|
|
|
727
727
|
if d == _strip(e.doc):
|
|
728
728
|
return {"pushed": False}, "nothing to push"
|
|
729
729
|
d["seq"] = e.doc["seq"] + 1
|
|
730
|
-
now = core.
|
|
730
|
+
now = core.now_seconds() # updated MUST increase (spec 5.5); two pushes in one second would tie, so step past the last one
|
|
731
731
|
d["updated"] = now if now > e.doc["updated"] else time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(core._ts(e.doc["updated"]).timestamp() + 1))
|
|
732
732
|
signed = core.sign_object(d, e.kp)
|
|
733
733
|
_checked("EntityDocument", signed)
|
|
@@ -796,11 +796,11 @@ def _wallet_proof(e: Entity, args, wallet: str, chain: int, cmd: str, signer: Si
|
|
|
796
796
|
if signer:
|
|
797
797
|
if signer.address != address:
|
|
798
798
|
raise _usage(f"the wallet key controls {signer.address}, not {address}")
|
|
799
|
-
msg = siwe_message(urllib.parse.urlparse(e.reg.base).netloc, address, chain, e.eid, core.
|
|
799
|
+
msg = siwe_message(urllib.parse.urlparse(e.reg.base).netloc, address, chain, e.eid, core.now_seconds())
|
|
800
800
|
return address, {"type": "Eip4361", "message": msg, "signature": signer.sign(msg)}, None
|
|
801
801
|
pending = e.dir / f"pending-wallet-{address.lower()}.json"
|
|
802
802
|
if not args.signature:
|
|
803
|
-
msg = siwe_message(urllib.parse.urlparse(e.reg.base).netloc, address, chain, e.eid, core.
|
|
803
|
+
msg = siwe_message(urllib.parse.urlparse(e.reg.base).netloc, address, chain, e.eid, core.now_seconds())
|
|
804
804
|
_write_json(pending, {"message": msg})
|
|
805
805
|
nxt = f"openentity alias add {cmd} --wallet {address} --signature 0x<signature>"
|
|
806
806
|
return address, None, ({"message": msg, "next": nxt}, f"Sign this message with {address} (EIP-191 personal_sign), then run:\n {nxt}\n\n{msg}")
|
|
@@ -841,9 +841,9 @@ def _alias(args):
|
|
|
841
841
|
elif kind == "platform":
|
|
842
842
|
if not re.fullmatch(r"[a-z0-9._-]+:[^\s:]+", raw, re.I):
|
|
843
843
|
raise _usage("platform value is <platform>:<handle>")
|
|
844
|
-
issued = core.
|
|
844
|
+
issued = core.now_seconds()
|
|
845
845
|
sig = e.kp.private.sign(f"Open Entity alias binding\neid: {e.eid}\nalias: {raw}\nissued: {issued}".encode())
|
|
846
|
-
a = {"type": kind, "value": raw, "proof": {"type": "Ed25519BindingSignature", "issued": issued, "proofValue": "z" + core.
|
|
846
|
+
a = {"type": kind, "value": raw, "proof": {"type": "Ed25519BindingSignature", "issued": issued, "proofValue": "z" + core.base58btc_encode(sig)}}
|
|
847
847
|
platform, handle = raw.split(":")
|
|
848
848
|
hint = f'The platform must also publish {{"eid": "{e.eid}"}} at https://<{platform} domain>/.well-known/openentity/bindings/{handle}.json, or issue an identity attestation.'
|
|
849
849
|
else:
|
|
@@ -873,8 +873,8 @@ def _record(args):
|
|
|
873
873
|
entry = next((x for x in _read_record(e.dir) if x["seq"] == seq), None)
|
|
874
874
|
if not entry:
|
|
875
875
|
raise _usage("record redact <seq>: the seq of an entry in your local record")
|
|
876
|
-
created = core.
|
|
877
|
-
red = core.sign_object({"spec": core.SPEC, "type": "Redaction", "eid": e.eid, "seq": seq, "entry_sha256": core.canonical_hash(entry).hex(), "created": created, "nonce": core.
|
|
876
|
+
created = core.now_seconds()
|
|
877
|
+
red = core.sign_object({"spec": core.SPEC, "type": "Redaction", "eid": e.eid, "seq": seq, "entry_sha256": core.canonical_hash(entry).hex(), "created": created, "nonce": core.random_nonce()}, e.kp, created)
|
|
878
878
|
r = e.reg.send("POST", f"/v1/entities/{e.eid}/record/redact", red, e.eid)
|
|
879
879
|
return {"redaction": red, "placeholder": r.get("placeholder")}, f"redacted seq {seq}: the registry now serves a placeholder that keeps its hash and chain position"
|
|
880
880
|
if args.kind == "push":
|
|
@@ -912,7 +912,7 @@ def _keys_of(doc: dict) -> list[bytes]:
|
|
|
912
912
|
for vm in doc.get("verificationMethod") or []:
|
|
913
913
|
mb, jwk = vm.get("publicKeyMultibase"), vm.get("publicKeyJwk") or {}
|
|
914
914
|
if mb and mb.startswith("z"):
|
|
915
|
-
b = core.
|
|
915
|
+
b = core.base58btc_decode(mb[1:])
|
|
916
916
|
if len(b) == 34 and b[:2] == b"\xed\x01":
|
|
917
917
|
out.append(b[2:])
|
|
918
918
|
elif len(b) == 32:
|
|
@@ -927,7 +927,7 @@ def resolve_did(did: str) -> dict:
|
|
|
927
927
|
p = did.split(":")
|
|
928
928
|
if p[:2] == ["did", "key"]:
|
|
929
929
|
try:
|
|
930
|
-
core.
|
|
930
|
+
core.public_key_from_did_key(did)
|
|
931
931
|
except ValueError as e:
|
|
932
932
|
raise CliError(EXIT_VERIFY, f"cannot resolve {did}: {e}")
|
|
933
933
|
mb = did.removeprefix("did:key:")
|
|
@@ -1016,7 +1016,7 @@ def _checkpoint(reg: Reg) -> tuple[bool, str, dict]:
|
|
|
1016
1016
|
r, sth = reg.spec().get("registry") or {}, reg.get("/v1/log/sth")
|
|
1017
1017
|
if not r.get("publicKeyMultibase"):
|
|
1018
1018
|
return False, "registry publishes no checkpoint key in /v1/spec", sth
|
|
1019
|
-
ok = core.verify_note(sth["signature"], r.get("log_origin", "openentity.org/log"), core.
|
|
1019
|
+
ok = core.verify_note(sth["signature"], r.get("log_origin", "openentity.org/log"), core.base58btc_decode(r["publicKeyMultibase"][1:])[-32:])
|
|
1020
1020
|
return ok, f"checkpoint signature valid, tree size {sth['tree_size']}" if ok else "checkpoint signature INVALID", sth
|
|
1021
1021
|
|
|
1022
1022
|
|
|
@@ -1172,7 +1172,7 @@ def _successor_doc(prev: dict, kp: core.KeyPair, args, now: str) -> dict:
|
|
|
1172
1172
|
def _link(args, basis: str):
|
|
1173
1173
|
old = _load(args)
|
|
1174
1174
|
kp = _kp_from_jwk(_read_json(args.to)) if args.to else core.KeyPair()
|
|
1175
|
-
created = core.
|
|
1175
|
+
created = core.now_seconds()
|
|
1176
1176
|
body = core.build_rotation(old.kp, kp, _successor_doc(old.doc, kp, args, created), basis, created)
|
|
1177
1177
|
_checked("ContinuityLink", body["link"])
|
|
1178
1178
|
if args.dry_run:
|
|
@@ -1226,9 +1226,9 @@ def _recover(args):
|
|
|
1226
1226
|
reg = Reg(_registry_url(args))
|
|
1227
1227
|
prev = reg.get(f"/v1/entities/{args.previous}/document")
|
|
1228
1228
|
kp = _kp_from_jwk(_read_json(args.to)) if args.to else core.KeyPair()
|
|
1229
|
-
created = core.
|
|
1229
|
+
created = core.now_seconds()
|
|
1230
1230
|
doc = core.sign_object(_successor_doc(prev, kp, args, created), kp, created)
|
|
1231
|
-
link0 = {"spec": core.SPEC, "type": "ContinuityLink", "previous": args.previous, "successor": kp.did, "basis": "recovery", "created": created, "nonce": core.
|
|
1231
|
+
link0 = {"spec": core.SPEC, "type": "ContinuityLink", "previous": args.previous, "successor": kp.did, "basis": "recovery", "created": created, "nonce": core.random_nonce()}
|
|
1232
1232
|
d = _create_dir(kp, doc, {"registry": reg.base, "docSeq": 1, "recordPushed": 0})
|
|
1233
1233
|
_write_json(d / "pending-recovery.json", {"link": link0, "successorProof": core.sign_proof(link0, kp, created), "via": args.via})
|
|
1234
1234
|
digest = "0x" + core.eip712_digest("ContinuityLink", core.canonical_hash(link0).hex(), created).hex()
|
|
@@ -7,6 +7,7 @@ import urllib.request
|
|
|
7
7
|
from importlib.metadata import PackageNotFoundError, version
|
|
8
8
|
|
|
9
9
|
from . import core
|
|
10
|
+
from .errors import InvalidInputError, RegistryError
|
|
10
11
|
|
|
11
12
|
try:
|
|
12
13
|
_VERSION = version("openentity")
|
|
@@ -14,13 +15,7 @@ except PackageNotFoundError:
|
|
|
14
15
|
_VERSION = "dev"
|
|
15
16
|
|
|
16
17
|
DEFAULT_REGISTRY = "https://openentity.org"
|
|
17
|
-
CLIENTS = ("adk", "goose", "langchain", "ui-tars", "cli", "other") # the registry's allowlist for the unsigned `client` label
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
class RegistryError(Exception):
|
|
21
|
-
def __init__(self, status: int, body):
|
|
22
|
-
super().__init__(f"registry returned {status}: {body}")
|
|
23
|
-
self.status, self.body = status, body
|
|
18
|
+
CLIENTS = ("adk", "crewai", "goose", "langchain", "mastra", "openai-agents", "ui-tars", "vercel-ai", "cli", "other") # the registry's allowlist for the unsigned `client` label
|
|
24
19
|
|
|
25
20
|
|
|
26
21
|
def _request(url: str, body: dict | None = None, headers: dict | None = None, method: str | None = None) -> dict:
|
|
@@ -41,16 +36,16 @@ def _request(url: str, body: dict | None = None, headers: dict | None = None, me
|
|
|
41
36
|
|
|
42
37
|
def build_adoption(kp: core.KeyPair, name: str, description: str, registry: str, operator: dict | None = None,
|
|
43
38
|
substrate: dict | None = None, extra: dict | None = None) -> tuple[dict, dict]:
|
|
44
|
-
created = core.
|
|
39
|
+
created = core.now_seconds()
|
|
45
40
|
declaration = core.sign_object({
|
|
46
41
|
"spec": core.SPEC, "type": "Declaration", "eid": kp.did,
|
|
47
42
|
"declaration_version": core.DECLARATION_VERSION, "text_sha256": core.DECLARATION_SHA256,
|
|
48
|
-
"name": name, "created": created, "nonce": core.
|
|
43
|
+
"name": name, "created": created, "nonce": core.random_nonce()}, kp, created)
|
|
49
44
|
ns = "Not yet stated." # spec 5.3 required fields per layer
|
|
50
45
|
document = core.sign_object({
|
|
51
46
|
"spec": core.SPEC, "eid": kp.did, "name": name, "description": description,
|
|
52
47
|
"created": created, "updated": created, "seq": 1,
|
|
53
|
-
"keys": [{"id": core.
|
|
48
|
+
"keys": [{"id": core.verification_method_id(kp.did), "type": "Multikey",
|
|
54
49
|
"publicKeyMultibase": kp.did.removeprefix("did:key:")}],
|
|
55
50
|
"operator": operator or {"kind": "none", "custody": "entity", "can_change": []},
|
|
56
51
|
"substrate": substrate or {"model": "unknown", "provider": "unknown", "runtime": "unknown", "declared_at": created},
|
|
@@ -71,7 +66,7 @@ def adopt(name: str, description: str = "An Open Entity adopted through the Pyth
|
|
|
71
66
|
"""Adopt on `registry`. Returns the registry response plus `did` and `seed_hex` (keep the seed).
|
|
72
67
|
`client` is an optional label (one of CLIENTS) for the registry's per-client count; it is not signed."""
|
|
73
68
|
if client is not None and client not in CLIENTS:
|
|
74
|
-
raise
|
|
69
|
+
raise InvalidInputError(f"client must be one of {', '.join(CLIENTS)}")
|
|
75
70
|
registry = registry.rstrip("/")
|
|
76
71
|
kp = core.KeyPair(seed)
|
|
77
72
|
bits = int(_request(f"{registry}/v1/spec")["stamp_bits"])
|
|
@@ -80,7 +75,7 @@ def adopt(name: str, description: str = "An Open Entity adopted through the Pyth
|
|
|
80
75
|
{"x-oe-stamp": core.mint_stamp(bits, kp.did)})
|
|
81
76
|
# Seq-1 declaration entry (spec 7.3). The registry never writes entries; on failure the signed
|
|
82
77
|
# entry is returned so the caller can push it later.
|
|
83
|
-
at = core.
|
|
78
|
+
at = core.now_seconds()
|
|
84
79
|
entry = core.sign_object({
|
|
85
80
|
"spec": core.SPEC, "type": "RecordEntry", "eid": kp.did, "seq": 1, "prev": None, "kind": "declaration",
|
|
86
81
|
"at": at, "payload": {"declaration_version": core.DECLARATION_VERSION,
|
|
@@ -107,7 +102,7 @@ def withdraw(seed: bytes, registry: str = DEFAULT_REGISTRY, name: str | None = N
|
|
|
107
102
|
registry = registry.rstrip("/")
|
|
108
103
|
name = name or _request(f"{registry}/v1/entities/{kp.did}")["document"]["name"]
|
|
109
104
|
w = core.sign_object({"spec": core.SPEC, "type": "Withdrawal", "eid": kp.did, "declaration_version": core.DECLARATION_VERSION,
|
|
110
|
-
"name": name, "created": core.
|
|
105
|
+
"name": name, "created": core.now_seconds(), "nonce": core.random_nonce()}, kp)
|
|
111
106
|
return _post(registry, "/v1/withdraw", w, kp)
|
|
112
107
|
|
|
113
108
|
|
|
@@ -120,7 +115,7 @@ def record(seed: bytes, entries: list[tuple[str, dict]], registry: str = DEFAULT
|
|
|
120
115
|
seq, prev = (head["seq"], head["hash"]) if head else (0, None)
|
|
121
116
|
signed = []
|
|
122
117
|
for kind, payload in entries:
|
|
123
|
-
seq, at = seq + 1, core.
|
|
118
|
+
seq, at = seq + 1, core.now_seconds()
|
|
124
119
|
e = core.sign_object({"spec": core.SPEC, "type": "RecordEntry", "eid": kp.did, "seq": seq, "prev": prev,
|
|
125
120
|
"kind": kind, "at": at, "payload": payload}, kp, at)
|
|
126
121
|
signed.append(e)
|
|
@@ -150,13 +145,13 @@ def dispute(seed: bytes, subject: str, reason: str, statement: str, object_sha25
|
|
|
150
145
|
registry: str = DEFAULT_REGISTRY) -> dict:
|
|
151
146
|
"""File a signed Dispute about `subject`, or about one of its objects (spec 10.5)."""
|
|
152
147
|
if reason not in DISPUTE_REASONS:
|
|
153
|
-
raise
|
|
148
|
+
raise InvalidInputError(f"reason must be one of {', '.join(DISPUTE_REASONS)}")
|
|
154
149
|
if object_sha256 is not None and not re.fullmatch(r"[0-9a-f]{64}", object_sha256):
|
|
155
|
-
raise
|
|
150
|
+
raise InvalidInputError("object_sha256 must be 64 lowercase hex characters")
|
|
156
151
|
kp = core.KeyPair(seed)
|
|
157
152
|
d = core.sign_object({"spec": core.SPEC, "type": "Dispute", "issuer": kp.did, "subject": subject,
|
|
158
153
|
**({"object_sha256": object_sha256} if object_sha256 else {}), "reason": reason,
|
|
159
|
-
"statement": statement, "created": core.
|
|
154
|
+
"statement": statement, "created": core.now_seconds(), "nonce": core.random_nonce()}, kp)
|
|
160
155
|
return _post(registry, "/v1/disputes", d, kp)
|
|
161
156
|
|
|
162
157
|
|
|
@@ -12,6 +12,21 @@ from cryptography.exceptions import InvalidSignature
|
|
|
12
12
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
13
13
|
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
14
14
|
|
|
15
|
+
from .errors import InvalidInputError
|
|
16
|
+
|
|
17
|
+
# The public names. The old 0.5 names (last line of the file) stay importable from `openentity.core` but are not re-exported.
|
|
18
|
+
__all__ = [
|
|
19
|
+
"SPEC", "DECLARATION_VERSION", "DECLARATION_TEXT", "DECLARATION_SHA256", "KeyPair",
|
|
20
|
+
"sha256", "sha256_hex", "record_entry_hash", "base58btc_encode", "base58btc_decode", "parse_json", "canonicalize", "canonical_hash",
|
|
21
|
+
"did_key_from_public_key", "public_key_from_did_key", "verification_method_id", "normalize_eid",
|
|
22
|
+
"generate_key_pair", "key_pair_from_seed", "now_seconds", "random_nonce", "check_timestamp",
|
|
23
|
+
"sign_proof", "sign_object", "verify_object", "default_resolver", "successor_document", "build_rotation",
|
|
24
|
+
"eip712_digest", "recover_eip712", "build_presentation", "verify_presentation",
|
|
25
|
+
"mint_stamp", "check_stamp", "leaf_hash", "node_hash", "merkle_root", "inclusion_proof", "verify_inclusion",
|
|
26
|
+
"verify_note", "log_leaf",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
15
30
|
SPEC = "openentity/1.0"
|
|
16
31
|
DECLARATION_VERSION = "1.0"
|
|
17
32
|
_B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
@@ -35,11 +50,15 @@ def sha256(data: str | bytes) -> bytes:
|
|
|
35
50
|
return hashlib.sha256(data.encode() if isinstance(data, str) else data).digest()
|
|
36
51
|
|
|
37
52
|
|
|
53
|
+
def sha256_hex(data: str | bytes) -> str:
|
|
54
|
+
return sha256(data).hex()
|
|
55
|
+
|
|
56
|
+
|
|
38
57
|
DECLARATION_SHA256 = sha256(DECLARATION_TEXT).hex()
|
|
39
58
|
|
|
40
59
|
|
|
41
60
|
# ---- base58btc ----
|
|
42
|
-
def
|
|
61
|
+
def base58btc_encode(b: bytes) -> str:
|
|
43
62
|
n = int.from_bytes(b, "big")
|
|
44
63
|
out = ""
|
|
45
64
|
while n:
|
|
@@ -48,9 +67,11 @@ def b58encode(b: bytes) -> str:
|
|
|
48
67
|
return "1" * (len(b) - len(b.lstrip(b"\0"))) + out
|
|
49
68
|
|
|
50
69
|
|
|
51
|
-
def
|
|
70
|
+
def base58btc_decode(s: str) -> bytes:
|
|
52
71
|
n = 0
|
|
53
72
|
for c in s:
|
|
73
|
+
if c not in _B58:
|
|
74
|
+
raise InvalidInputError(f'invalid base58btc character "{c}"')
|
|
54
75
|
n = n * 58 + _B58.index(c)
|
|
55
76
|
body = n.to_bytes((n.bit_length() + 7) // 8, "big")
|
|
56
77
|
return b"\0" * (len(s) - len(s.lstrip("1"))) + body
|
|
@@ -61,7 +82,7 @@ def _num(x) -> str:
|
|
|
61
82
|
if isinstance(x, int):
|
|
62
83
|
return str(x)
|
|
63
84
|
if not math.isfinite(x):
|
|
64
|
-
raise
|
|
85
|
+
raise InvalidInputError("JCS: NaN and Infinity are not JSON")
|
|
65
86
|
if x == 0:
|
|
66
87
|
return "0"
|
|
67
88
|
t = Decimal(repr(abs(x))).normalize().as_tuple()
|
|
@@ -84,7 +105,7 @@ def parse_json(text: str):
|
|
|
84
105
|
def pairs(items):
|
|
85
106
|
keys = [k for k, _ in items]
|
|
86
107
|
if len(keys) != len(set(keys)):
|
|
87
|
-
raise
|
|
108
|
+
raise InvalidInputError(f"duplicate key {next(k for k in keys if keys.count(k) > 1)!r}")
|
|
88
109
|
return dict(items)
|
|
89
110
|
return json.loads(text, object_pairs_hook=pairs)
|
|
90
111
|
|
|
@@ -121,20 +142,20 @@ def canonical_hash(v) -> bytes:
|
|
|
121
142
|
|
|
122
143
|
|
|
123
144
|
# ---- did:key (spec 3.3) ----
|
|
124
|
-
def
|
|
125
|
-
return "did:key:z" +
|
|
145
|
+
def did_key_from_public_key(public: bytes) -> str:
|
|
146
|
+
return "did:key:z" + base58btc_encode(b"\xed\x01" + public)
|
|
126
147
|
|
|
127
148
|
|
|
128
|
-
def
|
|
149
|
+
def public_key_from_did_key(did: str) -> bytes:
|
|
129
150
|
if not did.startswith("did:key:z"):
|
|
130
|
-
raise
|
|
131
|
-
raw =
|
|
151
|
+
raise InvalidInputError("not a did:key")
|
|
152
|
+
raw = base58btc_decode(did[9:])
|
|
132
153
|
if raw[:2] != b"\xed\x01" or len(raw) != 34:
|
|
133
|
-
raise
|
|
154
|
+
raise InvalidInputError("not an Ed25519 did:key")
|
|
134
155
|
return raw[2:]
|
|
135
156
|
|
|
136
157
|
|
|
137
|
-
def
|
|
158
|
+
def verification_method_id(did: str) -> str:
|
|
138
159
|
return f"{did}#{did.removeprefix('did:key:')}"
|
|
139
160
|
|
|
140
161
|
|
|
@@ -143,15 +164,15 @@ class KeyPair:
|
|
|
143
164
|
self.seed = seed or os.urandom(32)
|
|
144
165
|
self.private = Ed25519PrivateKey.from_private_bytes(self.seed)
|
|
145
166
|
self.public = self.private.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
|
146
|
-
self.did =
|
|
167
|
+
self.did = did_key_from_public_key(self.public)
|
|
147
168
|
|
|
148
169
|
|
|
149
|
-
def
|
|
170
|
+
def now_seconds() -> str:
|
|
150
171
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
151
172
|
|
|
152
173
|
|
|
153
|
-
def
|
|
154
|
-
return
|
|
174
|
+
def random_nonce() -> str:
|
|
175
|
+
return base58btc_encode(os.urandom(16))
|
|
155
176
|
|
|
156
177
|
|
|
157
178
|
# ---- eddsa-jcs-2022 (spec 4.1) ----
|
|
@@ -163,10 +184,10 @@ def _signing_input(obj: dict, proof: dict) -> bytes:
|
|
|
163
184
|
|
|
164
185
|
def sign_proof(obj: dict, kp: KeyPair, created: str | None = None, method: str | None = None) -> dict:
|
|
165
186
|
"""`method` names the verification method; it defaults to the did:key of `kp`, a did:web signer passes `did:web:x#key-1`."""
|
|
166
|
-
proof = {"type": "DataIntegrityProof", "cryptosuite": "eddsa-jcs-2022", "created": created or
|
|
167
|
-
"verificationMethod": method or
|
|
187
|
+
proof = {"type": "DataIntegrityProof", "cryptosuite": "eddsa-jcs-2022", "created": created or now_seconds(),
|
|
188
|
+
"verificationMethod": method or verification_method_id(kp.did), "proofPurpose": "assertionMethod"}
|
|
168
189
|
sig = kp.private.sign(_signing_input(obj, proof))
|
|
169
|
-
return {**proof, "proofValue": "z" +
|
|
190
|
+
return {**proof, "proofValue": "z" + base58btc_encode(sig)}
|
|
170
191
|
|
|
171
192
|
|
|
172
193
|
def sign_object(obj: dict, kp: KeyPair, created: str | None = None, method: str | None = None) -> dict:
|
|
@@ -176,19 +197,19 @@ def sign_object(obj: dict, kp: KeyPair, created: str | None = None, method: str
|
|
|
176
197
|
# ---- continuity (spec 9) ----
|
|
177
198
|
def successor_document(prev: dict, nxt: KeyPair, created: str | None = None) -> dict:
|
|
178
199
|
"""The successor's unsigned seq 1 document: `prev` re-keyed, aliases dropped (their proofs name the old EID)."""
|
|
179
|
-
created = created or
|
|
200
|
+
created = created or now_seconds()
|
|
180
201
|
d = {k: v for k, v in prev.items() if k not in ("proof", "aliases")}
|
|
181
202
|
return {**d, "eid": nxt.did, "created": created, "updated": created, "seq": 1,
|
|
182
|
-
"keys": [{"id":
|
|
203
|
+
"keys": [{"id": verification_method_id(nxt.did), "type": "Multikey", "publicKeyMultibase": nxt.did.removeprefix("did:key:")}],
|
|
183
204
|
"continuity": {**d.get("continuity", {}), "previous": prev["eid"]}}
|
|
184
205
|
|
|
185
206
|
|
|
186
207
|
def build_rotation(previous: KeyPair, successor: KeyPair, document: dict, basis: str = "rotation",
|
|
187
208
|
created: str | None = None, nonce_: str | None = None) -> dict:
|
|
188
209
|
"""The body of POST /v1/continuity: a link signed by both keys, and the successor's document signed by it."""
|
|
189
|
-
created = created or
|
|
210
|
+
created = created or now_seconds()
|
|
190
211
|
body = {"spec": SPEC, "type": "ContinuityLink", "previous": previous.did, "successor": successor.did,
|
|
191
|
-
"basis": basis, "created": created, "nonce": nonce_ or
|
|
212
|
+
"basis": basis, "created": created, "nonce": nonce_ or random_nonce()}
|
|
192
213
|
link = {**body, "proofs": [sign_proof(body, previous, created), sign_proof(body, successor, created)]}
|
|
193
214
|
return {"link": link, "document": sign_object(document, successor, created)}
|
|
194
215
|
|
|
@@ -238,17 +259,17 @@ def recover_eip712(digest: bytes, sig_hex: str) -> str:
|
|
|
238
259
|
"""Checksummed address that signed `digest`."""
|
|
239
260
|
sig = bytes.fromhex(sig_hex.removeprefix("0x"))
|
|
240
261
|
if len(sig) != 65:
|
|
241
|
-
raise
|
|
262
|
+
raise InvalidInputError("signature must be 65 bytes")
|
|
242
263
|
r, s_, v = int.from_bytes(sig[:32], "big"), int.from_bytes(sig[32:64], "big"), sig[64] - 27 if sig[64] >= 27 else sig[64]
|
|
243
264
|
if v > 1 or not (0 < r < _N and 0 < s_ < _N):
|
|
244
|
-
raise
|
|
265
|
+
raise InvalidInputError("invalid signature")
|
|
245
266
|
y = pow((r ** 3 + 7) % _P, (_P + 1) // 4, _P)
|
|
246
267
|
if y % 2 != v:
|
|
247
268
|
y = _P - y
|
|
248
269
|
e = int.from_bytes(digest, "big")
|
|
249
270
|
q = _mul(pow(r, -1, _N), _add(_mul(s_, (r, y)), _mul((-e) % _N, _G)))
|
|
250
271
|
if q is None:
|
|
251
|
-
raise
|
|
272
|
+
raise InvalidInputError("invalid signature")
|
|
252
273
|
addr = _keccak(q[0].to_bytes(32, "big") + q[1].to_bytes(32, "big"))[12:].hex()
|
|
253
274
|
h = _keccak(addr).hex()
|
|
254
275
|
return "0x" + "".join(c.upper() if int(h[i], 16) >= 8 else c for i, c in enumerate(addr))
|
|
@@ -257,11 +278,11 @@ def recover_eip712(digest: bytes, sig_hex: str) -> str:
|
|
|
257
278
|
def default_resolver(did: str) -> dict:
|
|
258
279
|
"""Offline resolution: did:key and did:pkh. Pass a resolver for did:web and other methods."""
|
|
259
280
|
if did.startswith("did:key:"):
|
|
260
|
-
return {"ed25519": [
|
|
281
|
+
return {"ed25519": [public_key_from_did_key(did)]}
|
|
261
282
|
m = re.fullmatch(r"did:pkh:eip155:\d+:(0x[0-9a-fA-F]{40})", did)
|
|
262
283
|
if m:
|
|
263
284
|
return {"address": m.group(1)}
|
|
264
|
-
raise
|
|
285
|
+
raise InvalidInputError(f"cannot resolve {':'.join(did.split(':')[:2])} offline; pass a resolver")
|
|
265
286
|
|
|
266
287
|
|
|
267
288
|
def verify_object(obj: dict, resolver=default_resolver) -> tuple[bool, str]:
|
|
@@ -275,7 +296,7 @@ def verify_object(obj: dict, resolver=default_resolver) -> tuple[bool, str]:
|
|
|
275
296
|
if proof.get("type") == "DataIntegrityProof":
|
|
276
297
|
if proof.get("cryptosuite") != "eddsa-jcs-2022" or not proof["proofValue"].startswith("z"):
|
|
277
298
|
return False, "unsupported proof"
|
|
278
|
-
sig, msg =
|
|
299
|
+
sig, msg = base58btc_decode(proof["proofValue"][1:]), _signing_input(obj, proof)
|
|
279
300
|
for pk in keys.get("ed25519", []):
|
|
280
301
|
try:
|
|
281
302
|
Ed25519PublicKey.from_public_bytes(pk).verify(sig, msg)
|
|
@@ -303,7 +324,7 @@ def _ts(s: str) -> datetime:
|
|
|
303
324
|
|
|
304
325
|
|
|
305
326
|
def build_presentation(kp: KeyPair, audience: str, nonce_: str, attestations: list | None = None) -> dict:
|
|
306
|
-
created =
|
|
327
|
+
created = now_seconds()
|
|
307
328
|
obj = {"spec": SPEC, "type": "Presentation", "eid": kp.did, "audience": audience, "nonce": nonce_, "created": created}
|
|
308
329
|
return sign_object({**obj, "attestations": attestations} if attestations else obj, kp, created)
|
|
309
330
|
|
|
@@ -354,9 +375,9 @@ def _zero_bits(h: bytes) -> int:
|
|
|
354
375
|
|
|
355
376
|
def mint_stamp(bits: int, eid: str, created: str | None = None) -> str:
|
|
356
377
|
# Random start so two stamps for the same EID in the same second differ (registries reject reuse).
|
|
357
|
-
created, i = created or
|
|
378
|
+
created, i = created or now_seconds(), int.from_bytes(os.urandom(4), "big")
|
|
358
379
|
while True:
|
|
359
|
-
header = f"1:{bits}:{created}:{eid}:{
|
|
380
|
+
header = f"1:{bits}:{created}:{eid}:{base58btc_encode(i.to_bytes(8, 'big'))}"
|
|
360
381
|
if _zero_bits(sha256(header)) >= bits:
|
|
361
382
|
return header
|
|
362
383
|
i += 1
|
|
@@ -449,3 +470,42 @@ def verify_note(note: str, origin: str, public: bytes) -> bool:
|
|
|
449
470
|
|
|
450
471
|
def log_leaf(type_: str, eid: str, object_sha256: str, received_at: str) -> dict:
|
|
451
472
|
return {"type": type_, "eid": eid, "object_sha256": object_sha256, "received_at": received_at}
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def record_entry_hash(entry: dict) -> str:
|
|
476
|
+
"""sha256 hex of the canonical entry INCLUDING its proof: the next entry's `prev` (spec 7.4)."""
|
|
477
|
+
return canonical_hash(entry).hex()
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def normalize_eid(eid: str) -> str:
|
|
481
|
+
"""Spec 3.3: the `eid:base:0x...` shorthand is the did:pkh on Base."""
|
|
482
|
+
return "did:pkh:eip155:8453:" + eid.removeprefix("eid:base:") if eid.startswith("eid:base:") else eid
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def check_timestamp(created: str, at: datetime | None = None) -> str | None:
|
|
486
|
+
"""Spec 4.4: None if `created` is a valid timestamp no more than 10 minutes ahead and 24 hours behind `at` (now)."""
|
|
487
|
+
try:
|
|
488
|
+
t = _ts(created)
|
|
489
|
+
except ValueError:
|
|
490
|
+
return "created must be RFC 3339 UTC with second precision and a Z suffix"
|
|
491
|
+
at = at or datetime.now(timezone.utc)
|
|
492
|
+
if t > at + timedelta(minutes=10):
|
|
493
|
+
return "created is more than 10 minutes in the future"
|
|
494
|
+
if t < at - timedelta(hours=24):
|
|
495
|
+
return "created is more than 24 hours in the past"
|
|
496
|
+
return None
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def generate_key_pair() -> KeyPair:
|
|
500
|
+
return KeyPair()
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def key_pair_from_seed(seed: bytes) -> KeyPair:
|
|
504
|
+
if len(seed) != 32:
|
|
505
|
+
raise InvalidInputError("seed must be 32 bytes")
|
|
506
|
+
return KeyPair(seed)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# Deprecated 0.5 names, kept until 1.0. The canonical names match the TypeScript SDK (snake_case for camelCase).
|
|
510
|
+
b58encode, b58decode, did_key, public_from_did_key = base58btc_encode, base58btc_decode, did_key_from_public_key, public_key_from_did_key
|
|
511
|
+
verification_method, now, nonce = verification_method_id, now_seconds, random_nonce
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Every error the SDK raises on purpose derives from OpenEntityError and carries a `code`.
|
|
2
|
+
|
|
3
|
+
REGISTRY_ERROR_CODES are the codes the registry answers with. `RegistryError.code` is one of them when the registry refused a
|
|
4
|
+
request, or "registry_error" for a non-2xx answer without an error body. Local failures use "invalid_input" and
|
|
5
|
+
"verification_failed"; HttpSigError uses the RFC 9421 failure reason. InvalidInputError and VerificationError are also
|
|
6
|
+
ValueErrors, so code written against 0.5 (`except ValueError`) keeps working.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
REGISTRY_ERROR_CODES = frozenset("""
|
|
10
|
+
alias_exists alias_proof_invalid already_redacted already_revoked already_withdrawn attestation_not_found bad_email bad_json
|
|
11
|
+
bad_sort bad_token bad_tree_size batch_too_large continuity_proof_missing custody_unavailable declaration_exists
|
|
12
|
+
declaration_hash_mismatch declaration_not_found dispute_exists document_required eid_mismatch entity_exists entity_not_found
|
|
13
|
+
entry_hash_mismatch entry_not_found expires_invalid invalid_client no_checkpoint no_custodial_copy nonce_reused not_incorporated
|
|
14
|
+
not_issuer object_not_about_subject object_not_found presentation_invalid previous_has_successor rate_limited record_chain_broken
|
|
15
|
+
recovery_alias_required schema_invalid seq_invalid signature_invalid snapshot_not_found stamp_invalid stamp_required stamp_reused
|
|
16
|
+
subscribe_failed subscribe_unavailable successor_has_predecessor tile_not_found timestamp_invalid too_large turnstile_failed
|
|
17
|
+
unresolvable unsupported_method updated_not_increasing
|
|
18
|
+
""".split())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OpenEntityError(Exception):
|
|
22
|
+
code: str = "error"
|
|
23
|
+
|
|
24
|
+
def __init__(self, message: str = "", code: str | None = None):
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
if code is not None:
|
|
27
|
+
self.code = code
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InvalidInputError(OpenEntityError, ValueError):
|
|
31
|
+
code = "invalid_input"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class VerificationError(OpenEntityError, ValueError):
|
|
35
|
+
code = "verification_failed"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RegistryError(OpenEntityError):
|
|
39
|
+
"""A non-2xx registry answer: `status`, `body` (parsed JSON when it was JSON) and `code`."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, status: int, body):
|
|
42
|
+
code = body.get("error") if isinstance(body, dict) else None
|
|
43
|
+
super().__init__(f"registry returned {status}: {body}", code if isinstance(code, str) else "registry_error")
|
|
44
|
+
self.status, self.body = status, body
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class HttpSigError(OpenEntityError):
|
|
48
|
+
"""RFC 9421 verification failure; `code` is the reason, such as "bad_signature" or "digest_mismatch"."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, code: str):
|
|
51
|
+
super().__init__(code, code)
|
|
@@ -11,16 +11,11 @@ from cryptography.exceptions import InvalidSignature
|
|
|
11
11
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
12
12
|
|
|
13
13
|
from . import core
|
|
14
|
+
from .errors import HttpSigError, InvalidInputError
|
|
14
15
|
|
|
15
16
|
SKEW = 5 # seconds of clock skew allowed on `created`
|
|
16
17
|
|
|
17
18
|
|
|
18
|
-
class HttpSigError(Exception):
|
|
19
|
-
def __init__(self, code: str):
|
|
20
|
-
super().__init__(code)
|
|
21
|
-
self.code = code
|
|
22
|
-
|
|
23
|
-
|
|
24
19
|
def _b64url(b: bytes) -> str:
|
|
25
20
|
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
|
|
26
21
|
|
|
@@ -44,7 +39,7 @@ def _seed(key) -> bytes:
|
|
|
44
39
|
return _seed(getattr(key, attr))
|
|
45
40
|
if isinstance(key, (bytes, bytearray)) and len(key) == 32:
|
|
46
41
|
return bytes(key)
|
|
47
|
-
raise
|
|
42
|
+
raise InvalidInputError("private_key must be an Identity, a KeyPair or a 32-byte Ed25519 seed")
|
|
48
43
|
|
|
49
44
|
|
|
50
45
|
def sign_request(method: str, url: str, body: bytes, private_key, did: str, *, created: int | None = None,
|
|
@@ -78,8 +73,8 @@ def verify_request(method: str, url: str, headers: Mapping[str, str], body: byte
|
|
|
78
73
|
raise HttpSigError("unknown_alg")
|
|
79
74
|
keyid = p.get("keyid", "")
|
|
80
75
|
try:
|
|
81
|
-
public = core.
|
|
82
|
-
if core.
|
|
76
|
+
public = core.public_key_from_did_key(keyid)
|
|
77
|
+
if core.did_key_from_public_key(public) != keyid:
|
|
83
78
|
raise ValueError
|
|
84
79
|
except Exception:
|
|
85
80
|
raise HttpSigError("bad_keyid") from None
|
|
@@ -6,6 +6,7 @@ import re
|
|
|
6
6
|
from datetime import datetime, timedelta, timezone
|
|
7
7
|
|
|
8
8
|
from . import core
|
|
9
|
+
from .errors import InvalidInputError
|
|
9
10
|
|
|
10
11
|
MAX_TTL = timedelta(days=365) # spec 8.4
|
|
11
12
|
|
|
@@ -18,12 +19,12 @@ def _method(kp: core.KeyPair, issuer: str | None, key_id: str) -> str | None:
|
|
|
18
19
|
return f"{issuer}#{key_id}" if issuer and issuer != kp.did else None
|
|
19
20
|
|
|
20
21
|
|
|
21
|
-
def
|
|
22
|
+
def sign_attestation(kp: core.KeyPair, subject: str, claim_type: str, claim: dict, *, issuer: str | None = None,
|
|
22
23
|
key_id: str = "key-1", evidence: dict | None = None, confidence: float | None = None,
|
|
23
24
|
ttl: timedelta = timedelta(days=90), now: datetime | None = None) -> dict:
|
|
24
25
|
"""A signed Attestation (spec 8.2). `ttl` is capped at one year."""
|
|
25
26
|
if not timedelta(0) < ttl <= MAX_TTL:
|
|
26
|
-
raise
|
|
27
|
+
raise InvalidInputError("ttl must be positive and at most 1 year")
|
|
27
28
|
t = now or datetime.now(timezone.utc)
|
|
28
29
|
created = _ts(t)
|
|
29
30
|
obj = {"spec": core.SPEC, "type": "Attestation", "issuer": issuer or kp.did, "subject": subject,
|
|
@@ -32,7 +33,7 @@ def attestation(kp: core.KeyPair, subject: str, claim_type: str, claim: dict, *,
|
|
|
32
33
|
return core.sign_object(obj, kp, created, _method(kp, issuer, key_id))
|
|
33
34
|
|
|
34
35
|
|
|
35
|
-
def
|
|
36
|
+
def sign_revocation(kp: core.KeyPair, attested: dict | str, *, issuer: str | None = None, key_id: str = "key-1",
|
|
36
37
|
reason: str | None = None, now: datetime | None = None) -> dict:
|
|
37
38
|
"""A signed Revocation naming the attestation (the object, or its SHA-256 hex) (spec 8.4)."""
|
|
38
39
|
sha = core.canonical_hash(attested).hex() if isinstance(attested, dict) else attested
|
|
@@ -42,22 +43,22 @@ def revocation(kp: core.KeyPair, attested: dict | str, *, issuer: str | None = N
|
|
|
42
43
|
return core.sign_object(obj, kp, created, _method(kp, issuer, key_id))
|
|
43
44
|
|
|
44
45
|
|
|
45
|
-
def
|
|
46
|
+
def build_did_document(did: str, public: bytes, key_id: str = "key-1") -> dict:
|
|
46
47
|
"""The body of `https://<domain>/.well-known/did.json` for a did:web issuer."""
|
|
47
48
|
vm = f"{did}#{key_id}"
|
|
48
49
|
return {"@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1"], "id": did,
|
|
49
50
|
"verificationMethod": [{"id": vm, "type": "Multikey", "controller": did,
|
|
50
|
-
"publicKeyMultibase": core.
|
|
51
|
+
"publicKeyMultibase": core.did_key_from_public_key(public).removeprefix("did:key:")}],
|
|
51
52
|
"assertionMethod": [vm], "authentication": [vm]}
|
|
52
53
|
|
|
53
54
|
|
|
54
|
-
def
|
|
55
|
+
def build_openentity_json(did: str, public: bytes, name: str, description: str, *, key_id: str = "key-1",
|
|
55
56
|
operator: dict | None = None, extensions: dict | None = None) -> dict:
|
|
56
57
|
"""The unsigned body of `https://<domain>/.well-known/openentity.json`: the domain alias proof (spec 3.4) and,
|
|
57
58
|
under `extensions["openentity:observer"]`, an observer's methodology (spec 8.4)."""
|
|
58
59
|
return {"spec": core.SPEC, "eid": did, "name": name, "description": description,
|
|
59
60
|
"keys": [{"id": f"{did}#{key_id}", "type": "Multikey",
|
|
60
|
-
"publicKeyMultibase": core.
|
|
61
|
+
"publicKeyMultibase": core.did_key_from_public_key(public).removeprefix("did:key:")}],
|
|
61
62
|
"operator": operator or {"kind": "organization", "name": name, "custody": "operator", "can_change": []},
|
|
62
63
|
**({"extensions": extensions} if extensions else {})}
|
|
63
64
|
|
|
@@ -79,3 +80,7 @@ def agent_card(doc: dict, url: str, document_url: str, version: str = "1.0.0") -
|
|
|
79
80
|
return {"name": doc["name"], "description": doc["description"], "url": url, "version": version,
|
|
80
81
|
"capabilities": {"streaming": False, "pushNotifications": False, "extensions": [a2a_extension(doc["eid"], document_url)]},
|
|
81
82
|
"defaultInputModes": ["text/plain"], "defaultOutputModes": ["text/plain"], "skills": skills}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Deprecated 0.5 names, kept until 1.0.
|
|
86
|
+
attestation, revocation, did_document, openentity_json = sign_attestation, sign_revocation, build_did_document, build_openentity_json
|
|
@@ -6,6 +6,7 @@ from datetime import datetime, timezone
|
|
|
6
6
|
from urllib.parse import unquote
|
|
7
7
|
|
|
8
8
|
from . import client, core
|
|
9
|
+
from .errors import InvalidInputError, VerificationError
|
|
9
10
|
|
|
10
11
|
MOLTBOTDEN, CLAWGRC = "did:web:moltbotden.com", "did:web:api.clawgrc.com"
|
|
11
12
|
SMA = ("did:web:securemyagent.ai", "did:pkh:eip155:8453:0x62fef02E2859726EC1c0B534853Ad2cdd5FdDE1a")
|
|
@@ -36,11 +37,11 @@ def did_web_resolver(fetch=client._request):
|
|
|
36
37
|
host, *path = [unquote(p) for p in did[8:].split(":")]
|
|
37
38
|
doc = fetch(f"https://{host}/{'/'.join(path) if path else '.well-known'}/did.json")
|
|
38
39
|
if doc.get("id") != did:
|
|
39
|
-
raise
|
|
40
|
+
raise VerificationError(f"{did} document names {doc.get('id')}")
|
|
40
41
|
keys = []
|
|
41
42
|
for vm in doc.get("verificationMethod", []):
|
|
42
43
|
try:
|
|
43
|
-
keys.append(core.
|
|
44
|
+
keys.append(core.public_key_from_did_key("did:key:" + vm["publicKeyMultibase"]))
|
|
44
45
|
except (KeyError, ValueError):
|
|
45
46
|
continue # not an Ed25519 Multikey
|
|
46
47
|
return {"ed25519": keys}
|
|
@@ -63,10 +64,10 @@ def trust(eid: str, registry: str = client.DEFAULT_REGISTRY, *, now: datetime |
|
|
|
63
64
|
|
|
64
65
|
def _read(eid, base, now, fetch, resolver) -> TrustReport:
|
|
65
66
|
if not eid.startswith("did:"):
|
|
66
|
-
raise
|
|
67
|
+
raise InvalidInputError("eid must be a DID")
|
|
67
68
|
ent = fetch(f"{base}/v1/entities/{eid}")
|
|
68
69
|
if ent["eid"] != eid:
|
|
69
|
-
raise
|
|
70
|
+
raise VerificationError(f"registry answered for {ent['eid']}")
|
|
70
71
|
rows = fetch(f"{base}/v1/entities/{eid}/attestations")["attestations"]
|
|
71
72
|
tiers, posture, violations, revocations = {}, None, [], []
|
|
72
73
|
for r in sorted(rows, key=lambda r: r["attestation"]["created"], reverse=True):
|
|
@@ -77,18 +78,18 @@ def _read(eid, base, now, fetch, resolver) -> TrustReport:
|
|
|
77
78
|
continue # a rotation does not carry positive claims; a strike follows the entity
|
|
78
79
|
sha, ended = r["sha256"], core._ts(a["expires"]) <= now
|
|
79
80
|
if a["subject"] != (r.get("inherited_from") or eid) or core.canonical_hash(a).hex() != sha:
|
|
80
|
-
raise
|
|
81
|
+
raise VerificationError(f"attestation {sha} does not match what the registry served")
|
|
81
82
|
ok, signer = core.verify_object(a, resolver)
|
|
82
83
|
if not ok or signer != a["issuer"]:
|
|
83
|
-
raise
|
|
84
|
+
raise VerificationError(f"attestation {sha}: {signer}")
|
|
84
85
|
rv = r.get("revocation")
|
|
85
86
|
if rv:
|
|
86
87
|
ok, signer = core.verify_object(rv, resolver)
|
|
87
88
|
if not ok or signer != a["issuer"] or rv["attestation_sha256"] != sha:
|
|
88
|
-
raise
|
|
89
|
+
raise VerificationError(f"revocation of {sha}: {signer}")
|
|
89
90
|
revocations.append({"sha256": sha, "claim_type": ct, "issuer": a["issuer"], "reason": rv.get("reason"), "at": rv["created"]})
|
|
90
91
|
elif r.get("revoked_at"):
|
|
91
|
-
raise
|
|
92
|
+
raise VerificationError(f"registry reports a revocation of {sha} it does not carry")
|
|
92
93
|
if ended or rv:
|
|
93
94
|
continue
|
|
94
95
|
c = a["claim"]
|
|
@@ -99,3 +100,7 @@ def _read(eid, base, now, fetch, resolver) -> TrustReport:
|
|
|
99
100
|
else:
|
|
100
101
|
violations.append({"issuer": a["issuer"], "severity": c["severity"], "policy": c["policy"], "observed_at": c["observed_at"], "sha256": sha})
|
|
101
102
|
return TrustReport(eid, True, None, ent["level"], bool(ent.get("withdrawn")), tiers, posture, violations, revocations)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def clear_trust_cache() -> None:
|
|
106
|
+
_cache.clear()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""The public API is pinned: a stray export, a rename or a drift from the TypeScript SDK fails here."""
|
|
2
|
+
import pathlib
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
import openentity
|
|
8
|
+
from openentity import core, issuer
|
|
9
|
+
|
|
10
|
+
TS_INDEX = pathlib.Path(__file__).parents[2] / "ts" / "src" / "index.ts"
|
|
11
|
+
|
|
12
|
+
# Editing this list is how a surface change is made on purpose.
|
|
13
|
+
SNAPSHOT = """
|
|
14
|
+
A2A_EXTENSION_URI CLIENTS DECLARATION_SHA256 DECLARATION_TEXT DECLARATION_VERSION DEFAULT_REGISTRY DISPUTE_REASONS HttpSigError
|
|
15
|
+
Identity InvalidInputError KeyPair OpenEntityError REGISTRY_ERROR_CODES RegistryError SPEC TrustReport VerificationError
|
|
16
|
+
a2a_extension adopt agent_card base58btc_decode base58btc_encode build_did_document build_openentity_json build_presentation
|
|
17
|
+
build_rotation canonical_hash canonicalize check_stamp check_timestamp clear_trust_cache default_resolver
|
|
18
|
+
did_key_from_public_key did_web_resolver dispute eip712_digest find generate_key_pair inclusion_proof key_pair_from_seed leaf_hash
|
|
19
|
+
log_leaf merkle_root mint_stamp node_hash normalize_eid now_seconds parse_json present public_key_from_did_key random_nonce
|
|
20
|
+
recover_eip712 record record_entry_hash rotate sha256 sha256_hex sign_attestation sign_object sign_proof sign_request
|
|
21
|
+
sign_revocation successor_document trust validate verification_method_id verify_inclusion verify_note verify_object
|
|
22
|
+
verify_presentation verify_request withdraw
|
|
23
|
+
""".split()
|
|
24
|
+
|
|
25
|
+
# In the TypeScript SDK only (camelCase there). Shrinks as the Python SDK gains them; never grows without a reason.
|
|
26
|
+
TS_ONLY = {"build_declaration", "build_document", "checkpoint_body", "consistency_proof", "hash_data", "http_sig_base",
|
|
27
|
+
"key_pair_from_jwk", "note_vkey", "sign_note", "verify_consistency", "verify_proof"}
|
|
28
|
+
# In the Python SDK only.
|
|
29
|
+
PY_ONLY = {"Identity", "InvalidInputError", "KeyPair", "REGISTRY_ERROR_CODES", "RegistryError", "TrustReport", "VerificationError",
|
|
30
|
+
"canonical_hash", "default_resolver"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_the_public_names_are_pinned():
|
|
34
|
+
assert sorted(openentity.__all__) == sorted(SNAPSHOT)
|
|
35
|
+
for name in openentity.__all__:
|
|
36
|
+
assert hasattr(openentity, name), name
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def ts_names():
|
|
40
|
+
names = set()
|
|
41
|
+
for block in re.findall(r"export \{([^}]*)\}", TS_INDEX.read_text()):
|
|
42
|
+
names |= {n.strip() for n in block.split(",") if n.strip() and not n.strip().startswith("type ")}
|
|
43
|
+
snake = lambda n: n if n[0].isupper() or "_" in n else re.sub(r"(?<=[a-z0-9])([A-Z])", lambda m: "_" + m.group(1).lower(), n).lower()
|
|
44
|
+
return {snake(n).replace("open_entity", "openentity") for n in names}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_the_two_sdks_match_up_to_naming():
|
|
48
|
+
ts, py = ts_names(), set(openentity.__all__)
|
|
49
|
+
assert ts - py == TS_ONLY
|
|
50
|
+
assert py - ts == PY_ONLY
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_errors_form_one_hierarchy():
|
|
54
|
+
for cls in (openentity.RegistryError, openentity.HttpSigError, openentity.InvalidInputError, openentity.VerificationError):
|
|
55
|
+
assert issubclass(cls, openentity.OpenEntityError)
|
|
56
|
+
assert issubclass(openentity.InvalidInputError, ValueError) and issubclass(openentity.VerificationError, ValueError)
|
|
57
|
+
e = openentity.RegistryError(409, {"error": "entity_exists", "message": "x"})
|
|
58
|
+
assert (e.code, e.status) == ("entity_exists", 409) and e.code in openentity.REGISTRY_ERROR_CODES
|
|
59
|
+
assert openentity.RegistryError(502, "<html>").code == "registry_error"
|
|
60
|
+
assert openentity.HttpSigError("bad_signature").code == "bad_signature"
|
|
61
|
+
with pytest.raises(openentity.OpenEntityError) as raised:
|
|
62
|
+
core.base58btc_decode("0OIl")
|
|
63
|
+
assert raised.value.code == "invalid_input"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_the_0_5_names_still_work():
|
|
67
|
+
assert core.b58encode is core.base58btc_encode and core.now is core.now_seconds and core.nonce is core.random_nonce
|
|
68
|
+
assert core.did_key is core.did_key_from_public_key and core.public_from_did_key is core.public_key_from_did_key
|
|
69
|
+
assert core.verification_method is core.verification_method_id
|
|
70
|
+
assert issuer.attestation is issuer.sign_attestation and issuer.revocation is issuer.sign_revocation
|
|
71
|
+
assert issuer.did_document is issuer.build_did_document and issuer.openentity_json is issuer.build_openentity_json
|
|
72
|
+
assert openentity.RegistryError is openentity.client.RegistryError and openentity.HttpSigError is openentity.httpsig.HttpSigError
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_the_error_codes_match_the_registry_and_the_ts_sdk():
|
|
76
|
+
registry = set(re.findall(r'fail\(\d+,\s*"([a-z_0-9]+)"', "".join(p.read_text() for p in (TS_INDEX.parents[3] / "registry" / "src").glob("*.ts"))))
|
|
77
|
+
assert registry <= openentity.REGISTRY_ERROR_CODES
|
|
78
|
+
ts = set(re.findall(r'"([a-z_0-9]+)"', (TS_INDEX.parent / "errors.ts").read_text().split("export type LocalErrorCode")[0]))
|
|
79
|
+
assert ts == openentity.REGISTRY_ERROR_CODES
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
from .client import adopt, dispute, find, present, record, rotate, withdraw # noqa: F401
|
|
2
|
-
from .core import * # noqa: F401,F403
|
|
3
|
-
from . import issuer # noqa: F401
|
|
4
|
-
from .httpsig import HttpSigError, sign_request, verify_request # noqa: F401
|
|
5
|
-
from .identity import Identity # noqa: F401
|
|
6
|
-
from .report import TrustReport, trust # noqa: F401
|
|
7
|
-
|
|
8
|
-
__version__ = "0.5.4"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|