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.
Files changed (27) hide show
  1. {openentity-0.5.4 → openentity-0.6.0}/PKG-INFO +2 -2
  2. {openentity-0.5.4 → openentity-0.6.0}/README.md +1 -1
  3. openentity-0.6.0/openentity/__init__.py +27 -0
  4. {openentity-0.5.4 → openentity-0.6.0}/openentity/cli.py +17 -17
  5. {openentity-0.5.4 → openentity-0.6.0}/openentity/client.py +12 -17
  6. {openentity-0.5.4 → openentity-0.6.0}/openentity/core.py +91 -31
  7. openentity-0.6.0/openentity/errors.py +51 -0
  8. {openentity-0.5.4 → openentity-0.6.0}/openentity/httpsig.py +4 -9
  9. {openentity-0.5.4 → openentity-0.6.0}/openentity/issuer.py +12 -7
  10. {openentity-0.5.4 → openentity-0.6.0}/openentity/report.py +13 -8
  11. {openentity-0.5.4 → openentity-0.6.0}/pyproject.toml +1 -1
  12. openentity-0.6.0/tests/test_api.py +79 -0
  13. {openentity-0.5.4 → openentity-0.6.0}/uv.lock +1 -1
  14. openentity-0.5.4/openentity/__init__.py +0 -8
  15. {openentity-0.5.4 → openentity-0.6.0}/.gitignore +0 -0
  16. {openentity-0.5.4 → openentity-0.6.0}/openentity/identity.py +0 -0
  17. {openentity-0.5.4 → openentity-0.6.0}/openentity/schema.py +0 -0
  18. {openentity-0.5.4 → openentity-0.6.0}/openentity/schemas/openentity-1.0.json +0 -0
  19. {openentity-0.5.4 → openentity-0.6.0}/tests/test_cli.py +0 -0
  20. {openentity-0.5.4 → openentity-0.6.0}/tests/test_client.py +0 -0
  21. {openentity-0.5.4 → openentity-0.6.0}/tests/test_core.py +0 -0
  22. {openentity-0.5.4 → openentity-0.6.0}/tests/test_httpsig.py +0 -0
  23. {openentity-0.5.4 → openentity-0.6.0}/tests/test_identity.py +0 -0
  24. {openentity-0.5.4 → openentity-0.6.0}/tests/test_issuer.py +0 -0
  25. {openentity-0.5.4 → openentity-0.6.0}/tests/test_parity.py +0 -0
  26. {openentity-0.5.4 → openentity-0.6.0}/tests/test_trust.py +0 -0
  27. {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.5.4
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.now()
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.now()
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.now(), "nonce": core.nonce()}, e.kp)
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.b58decode(spec["registry"]["publicKeyMultibase"][1:])[-32:]
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.now() # updated MUST increase (spec 5.5); two pushes in one second would tie, so step past the last one
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.now())
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.now())
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.now()
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.b58encode(sig)}}
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.now()
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.nonce()}, e.kp, created)
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.b58decode(mb[1:])
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.public_from_did_key(did)
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.b58decode(r["publicKeyMultibase"][1:])[-32:])
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.now()
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.now()
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.nonce()}
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.now()
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.nonce()}, kp, created)
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.verification_method(kp.did), "type": "Multikey",
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 ValueError(f"client must be one of {', '.join(CLIENTS)}")
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.now()
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.now(), "nonce": core.nonce()}, kp)
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.now()
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 ValueError(f"reason must be one of {', '.join(DISPUTE_REASONS)}")
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 ValueError("object_sha256 must be 64 lowercase hex characters")
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.now(), "nonce": core.nonce()}, kp)
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 b58encode(b: bytes) -> str:
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 b58decode(s: str) -> bytes:
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 ValueError("JCS: NaN and Infinity are not JSON")
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 ValueError(f"duplicate key {next(k for k in keys if keys.count(k) > 1)!r}")
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 did_key(public: bytes) -> str:
125
- return "did:key:z" + b58encode(b"\xed\x01" + public)
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 public_from_did_key(did: str) -> bytes:
149
+ def public_key_from_did_key(did: str) -> bytes:
129
150
  if not did.startswith("did:key:z"):
130
- raise ValueError("not a did:key")
131
- raw = b58decode(did[9:])
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 ValueError("not an Ed25519 did:key")
154
+ raise InvalidInputError("not an Ed25519 did:key")
134
155
  return raw[2:]
135
156
 
136
157
 
137
- def verification_method(did: str) -> str:
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 = did_key(self.public)
167
+ self.did = did_key_from_public_key(self.public)
147
168
 
148
169
 
149
- def now() -> str:
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 nonce() -> str:
154
- return b58encode(os.urandom(16))
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 now(),
167
- "verificationMethod": method or verification_method(kp.did), "proofPurpose": "assertionMethod"}
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" + b58encode(sig)}
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 now()
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": verification_method(nxt.did), "type": "Multikey", "publicKeyMultibase": nxt.did.removeprefix("did:key:")}],
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 now()
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 nonce()}
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 ValueError("signature must be 65 bytes")
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 ValueError("invalid signature")
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 ValueError("invalid signature")
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": [public_from_did_key(did)]}
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 ValueError(f"cannot resolve {':'.join(did.split(':')[:2])} offline; pass a resolver")
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 = b58decode(proof["proofValue"][1:]), _signing_input(obj, proof)
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 = now()
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 now(), int.from_bytes(os.urandom(4), "big")
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}:{b58encode(i.to_bytes(8, 'big'))}"
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 ValueError("private_key must be an Identity, a KeyPair or a 32-byte Ed25519 seed")
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.public_from_did_key(keyid)
82
- if core.did_key(public) != keyid:
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 attestation(kp: core.KeyPair, subject: str, claim_type: str, claim: dict, *, issuer: str | None = None,
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 ValueError("ttl must be positive and at most 1 year")
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 revocation(kp: core.KeyPair, attested: dict | str, *, issuer: str | None = None, key_id: str = "key-1",
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 did_document(did: str, public: bytes, key_id: str = "key-1") -> dict:
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.did_key(public).removeprefix("did:key:")}],
51
+ "publicKeyMultibase": core.did_key_from_public_key(public).removeprefix("did:key:")}],
51
52
  "assertionMethod": [vm], "authentication": [vm]}
52
53
 
53
54
 
54
- def openentity_json(did: str, public: bytes, name: str, description: str, *, key_id: str = "key-1",
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.did_key(public).removeprefix("did:key:")}],
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 ValueError(f"{did} document names {doc.get('id')}")
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.public_from_did_key("did:key:" + vm["publicKeyMultibase"]))
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 ValueError("eid must be a DID")
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 ValueError(f"registry answered for {ent['eid']}")
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 ValueError(f"attestation {sha} does not match what the registry served")
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 ValueError(f"attestation {sha}: {signer}")
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 ValueError(f"revocation of {sha}: {signer}")
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 ValueError(f"registry reports a revocation of {sha} it does not carry")
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()
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openentity"
7
- version = "0.5.4"
7
+ version = "0.6.0"
8
8
  description = "Open Entity (OEIS-1) client: JCS, did:key, eddsa-jcs-2022, Merkle proofs, adopt()."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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
@@ -699,7 +699,7 @@ wheels = [
699
699
 
700
700
  [[package]]
701
701
  name = "openentity"
702
- version = "0.5.4"
702
+ version = "0.6.0"
703
703
  source = { editable = "." }
704
704
  dependencies = [
705
705
  { name = "cryptography" },
@@ -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