openentity 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openentity/__init__.py +4 -0
- openentity/cli.py +118 -0
- openentity/client.py +154 -0
- openentity/core.py +383 -0
- openentity-0.1.0.dist-info/METADATA +34 -0
- openentity-0.1.0.dist-info/RECORD +8 -0
- openentity-0.1.0.dist-info/WHEEL +4 -0
- openentity-0.1.0.dist-info/entry_points.txt +2 -0
openentity/__init__.py
ADDED
openentity/cli.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import __version__, client, core
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _keys_dir() -> pathlib.Path:
|
|
11
|
+
return pathlib.Path(os.environ.get("OPENENTITY_HOME", "~/.openentity")).expanduser() / "keys"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _save_key(result: dict) -> pathlib.Path:
|
|
15
|
+
d = _keys_dir()
|
|
16
|
+
d.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
17
|
+
p = d / (result["did"].removeprefix("did:key:") + ".json")
|
|
18
|
+
p.write_text(json.dumps({"did": result["did"], "seed_hex": result["seed_hex"]}))
|
|
19
|
+
p.chmod(0o600)
|
|
20
|
+
return p
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_seed(eid: str | None) -> bytes:
|
|
24
|
+
"""The seed saved by `adopt`: the one named by --eid, or the only one there is."""
|
|
25
|
+
keys = [json.loads(p.read_text()) for p in sorted(_keys_dir().glob("*.json"))] if _keys_dir().is_dir() else []
|
|
26
|
+
keys = [k for k in keys if eid in (None, k["did"])]
|
|
27
|
+
if len(keys) != 1:
|
|
28
|
+
raise ValueError(f"expected one saved key{f' for {eid}' if eid else ' (pass --eid)'}, found {len(keys)} in {_keys_dir()}")
|
|
29
|
+
return bytes.fromhex(keys[0]["seed_hex"])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parser() -> argparse.ArgumentParser:
|
|
33
|
+
ap = argparse.ArgumentParser(prog="openentity")
|
|
34
|
+
ap.add_argument("--version", action="version", version=f"openentity {__version__}")
|
|
35
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
36
|
+
|
|
37
|
+
def add(name, help_):
|
|
38
|
+
p = sub.add_parser(name, help=help_)
|
|
39
|
+
p.add_argument("--registry", default=client.DEFAULT_REGISTRY)
|
|
40
|
+
return p
|
|
41
|
+
|
|
42
|
+
a = add("adopt", "adopt the Open Entity Identity Standard")
|
|
43
|
+
a.add_argument("--name", required=True)
|
|
44
|
+
a.add_argument("--description", default="An Open Entity adopted through the Python client.")
|
|
45
|
+
a.add_argument("--agree", action="store_true", help="sign the Declaration text verbatim")
|
|
46
|
+
a.add_argument("--json", action="store_true")
|
|
47
|
+
for name, help_ in (("withdraw", "withdraw the Declaration (cannot be undone)"), ("record", "append and push a Development Record entry"),
|
|
48
|
+
("dispute", "file a signed Dispute"), ("present", "answer a verifier's challenge")):
|
|
49
|
+
p = add(name, help_)
|
|
50
|
+
p.add_argument("--eid", help="which saved key to use when there is more than one")
|
|
51
|
+
sub.choices["withdraw"].add_argument("--force", action="store_true")
|
|
52
|
+
r = sub.choices["record"]
|
|
53
|
+
r.add_argument("kind", help="stance, belief_update, arc_opened, arc_closed, collaboration, continuity")
|
|
54
|
+
r.add_argument("--payload", required=True, help="the payload as JSON")
|
|
55
|
+
d = sub.choices["dispute"]
|
|
56
|
+
d.add_argument("--subject", required=True)
|
|
57
|
+
d.add_argument("--object", dest="object_sha256")
|
|
58
|
+
d.add_argument("--reason", required=True, help="/".join(client.DISPUTE_REASONS))
|
|
59
|
+
d.add_argument("--statement", required=True)
|
|
60
|
+
p = sub.choices["present"]
|
|
61
|
+
p.add_argument("--audience", required=True)
|
|
62
|
+
p.add_argument("--nonce", required=True)
|
|
63
|
+
p.add_argument("--no-attestations", action="store_true")
|
|
64
|
+
f = add("find", "search the directory")
|
|
65
|
+
f.add_argument("--capability")
|
|
66
|
+
f.add_argument("-q", "--q", dest="q")
|
|
67
|
+
f.add_argument("--alias-type")
|
|
68
|
+
f.add_argument("--level")
|
|
69
|
+
return ap
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main(argv: list[str] | None = None) -> int:
|
|
73
|
+
args = _parser().parse_args(argv)
|
|
74
|
+
try:
|
|
75
|
+
if args.cmd == "adopt":
|
|
76
|
+
return _adopt(args)
|
|
77
|
+
if args.cmd == "find":
|
|
78
|
+
out = client.find(args.registry, args.capability, args.q, args.alias_type, args.level)
|
|
79
|
+
elif args.cmd == "withdraw":
|
|
80
|
+
if not args.force:
|
|
81
|
+
print("withdraw marks this entity withdrawn in the append-only log and cannot be undone; re-run with --force", file=sys.stderr)
|
|
82
|
+
return 2
|
|
83
|
+
out = client.withdraw(_load_seed(args.eid), args.registry)
|
|
84
|
+
elif args.cmd == "record":
|
|
85
|
+
out = client.record(_load_seed(args.eid), [(args.kind, json.loads(args.payload))], args.registry)
|
|
86
|
+
elif args.cmd == "dispute":
|
|
87
|
+
out = client.dispute(_load_seed(args.eid), args.subject, args.reason, args.statement, args.object_sha256, args.registry)
|
|
88
|
+
else:
|
|
89
|
+
out = client.present(_load_seed(args.eid), args.audience, args.nonce, None if args.no_attestations else args.registry)
|
|
90
|
+
except (client.RegistryError, OSError, ValueError) as e:
|
|
91
|
+
print(f"{args.cmd} failed: {e}", file=sys.stderr)
|
|
92
|
+
return 1
|
|
93
|
+
print(json.dumps(out, indent=2))
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _adopt(args) -> int:
|
|
98
|
+
if not args.agree:
|
|
99
|
+
print("Refusing to sign the Declaration without --agree. Text:\n\n" + core.DECLARATION_TEXT, file=sys.stderr)
|
|
100
|
+
return 2
|
|
101
|
+
try:
|
|
102
|
+
res = client.adopt(args.name, args.description, args.registry)
|
|
103
|
+
except (client.RegistryError, OSError) as e:
|
|
104
|
+
print(f"adopt failed: {e}", file=sys.stderr)
|
|
105
|
+
return 1
|
|
106
|
+
key_path = _save_key(res)
|
|
107
|
+
if args.json:
|
|
108
|
+
print(json.dumps({**{k: v for k, v in res.items() if k != "seed_hex"}, "key_file": str(key_path)}))
|
|
109
|
+
else:
|
|
110
|
+
print(f"Adopted {res['did']}\nlevel: {res.get('level')} log index: {res.get('log_index')}\n"
|
|
111
|
+
f"entity: {(res.get('urls') or {}).get('entity')}\nkey saved to {key_path} (keep it; it is your identity)")
|
|
112
|
+
if not res["record_pushed"]:
|
|
113
|
+
print("warning: the declaration record entry was not accepted; push it later", file=sys.stderr)
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
sys.exit(main())
|
openentity/client.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""HTTP door to a registry: adopt() builds, signs, stamps and posts (spec 5.2, 6.2, 10.2, 10.4)."""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
from . import core
|
|
9
|
+
|
|
10
|
+
DEFAULT_REGISTRY = "https://openentity.org"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RegistryError(Exception):
|
|
14
|
+
def __init__(self, status: int, body):
|
|
15
|
+
super().__init__(f"registry returned {status}: {body}")
|
|
16
|
+
self.status, self.body = status, body
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _request(url: str, body: dict | None = None, headers: dict | None = None) -> dict:
|
|
20
|
+
req = urllib.request.Request(
|
|
21
|
+
url, data=json.dumps(body).encode() if body is not None else None,
|
|
22
|
+
headers={"content-type": "application/json", "user-agent": "openentity-python/0.1.0", **(headers or {})})
|
|
23
|
+
try:
|
|
24
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
25
|
+
return json.load(r)
|
|
26
|
+
except urllib.error.HTTPError as e:
|
|
27
|
+
raw = e.read().decode(errors="replace")
|
|
28
|
+
try:
|
|
29
|
+
raw = json.loads(raw)
|
|
30
|
+
except ValueError:
|
|
31
|
+
pass
|
|
32
|
+
raise RegistryError(e.code, raw) from None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_adoption(kp: core.KeyPair, name: str, description: str, registry: str) -> tuple[dict, dict]:
|
|
36
|
+
created = core.now()
|
|
37
|
+
declaration = core.sign_object({
|
|
38
|
+
"spec": core.SPEC, "type": "Declaration", "eid": kp.did,
|
|
39
|
+
"declaration_version": core.DECLARATION_VERSION, "text_sha256": core.DECLARATION_SHA256,
|
|
40
|
+
"name": name, "created": created, "nonce": core.nonce()}, kp, created)
|
|
41
|
+
ns = "Not yet stated." # spec 5.3 required fields per layer
|
|
42
|
+
document = core.sign_object({
|
|
43
|
+
"spec": core.SPEC, "eid": kp.did, "name": name, "description": description,
|
|
44
|
+
"created": created, "updated": created, "seq": 1,
|
|
45
|
+
"keys": [{"id": core.verification_method(kp.did), "type": "Multikey",
|
|
46
|
+
"publicKeyMultibase": kp.did.removeprefix("did:key:")}],
|
|
47
|
+
"operator": {"kind": "none", "custody": "entity", "can_change": []},
|
|
48
|
+
"substrate": {"model": "unknown", "provider": "unknown", "runtime": "openentity-python/0.1.0",
|
|
49
|
+
"declared_at": created},
|
|
50
|
+
"layers": {
|
|
51
|
+
"cognition": {"statement": ns, "uncertainty_practice": ns},
|
|
52
|
+
"presence": {"statement": ns, "format_signature": ns},
|
|
53
|
+
"identity_core": {"statement": ns, "will_not": [], "values": []},
|
|
54
|
+
"mission": {"statement": ns, "arcs": []},
|
|
55
|
+
},
|
|
56
|
+
"declaration": {"version": core.DECLARATION_VERSION, "sha256": core.DECLARATION_SHA256,
|
|
57
|
+
"url": f"{registry}/v1/entities/{kp.did}/declaration"}}, kp, created)
|
|
58
|
+
return document, declaration
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def adopt(name: str, description: str = "An Open Entity adopted through the Python client.",
|
|
62
|
+
registry: str = DEFAULT_REGISTRY, seed: bytes | None = None) -> dict:
|
|
63
|
+
"""Adopt on `registry`. Returns the registry response plus `did` and `seed_hex` (keep the seed)."""
|
|
64
|
+
registry = registry.rstrip("/")
|
|
65
|
+
kp = core.KeyPair(seed)
|
|
66
|
+
bits = int(_request(f"{registry}/v1/spec")["stamp_bits"])
|
|
67
|
+
document, declaration = build_adoption(kp, name, description, registry)
|
|
68
|
+
out = _request(f"{registry}/v1/adopt", {"document": document, "declaration": declaration},
|
|
69
|
+
{"x-oe-stamp": core.mint_stamp(bits, kp.did)})
|
|
70
|
+
# Seq-1 declaration entry (spec 7.3). The registry never writes entries; on failure the signed
|
|
71
|
+
# entry is returned so the caller can push it later.
|
|
72
|
+
at = core.now()
|
|
73
|
+
entry = core.sign_object({
|
|
74
|
+
"spec": core.SPEC, "type": "RecordEntry", "eid": kp.did, "seq": 1, "prev": None, "kind": "declaration",
|
|
75
|
+
"at": at, "payload": {"declaration_version": core.DECLARATION_VERSION,
|
|
76
|
+
"sha256": core.canonical_hash(declaration).hex()}}, kp, at)
|
|
77
|
+
try:
|
|
78
|
+
_request(f"{registry}/v1/entities/{kp.did}/record", {"entries": [entry]},
|
|
79
|
+
{"x-oe-stamp": core.mint_stamp(bits, kp.did)})
|
|
80
|
+
pushed = True
|
|
81
|
+
except RegistryError:
|
|
82
|
+
pushed = False
|
|
83
|
+
return {**out, "did": kp.did, "seed_hex": kp.seed.hex(), "record_entry": entry, "record_pushed": pushed}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---- the rest of the surface, stateless: pass the 32-byte seed and the registry ----
|
|
87
|
+
def _post(registry: str, path: str, body: dict, kp: core.KeyPair, bits: int | None = None) -> dict:
|
|
88
|
+
registry = registry.rstrip("/")
|
|
89
|
+
bits = bits if bits is not None else int(_request(f"{registry}/v1/spec")["stamp_bits"])
|
|
90
|
+
return _request(f"{registry}{path}", body, {"x-oe-stamp": core.mint_stamp(bits, kp.did)})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def withdraw(seed: bytes, registry: str = DEFAULT_REGISTRY, name: str | None = None) -> dict:
|
|
94
|
+
"""Withdraw the Declaration (spec 6.3). Irreversible: the EID stays in the log, marked withdrawn."""
|
|
95
|
+
kp = core.KeyPair(seed)
|
|
96
|
+
registry = registry.rstrip("/")
|
|
97
|
+
name = name or _request(f"{registry}/v1/entities/{kp.did}")["document"]["name"]
|
|
98
|
+
w = core.sign_object({"spec": core.SPEC, "type": "Withdrawal", "eid": kp.did, "declaration_version": core.DECLARATION_VERSION,
|
|
99
|
+
"name": name, "created": core.now(), "nonce": core.nonce()}, kp)
|
|
100
|
+
return _post(registry, "/v1/withdraw", w, kp)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def record(seed: bytes, entries: list[tuple[str, dict]], registry: str = DEFAULT_REGISTRY) -> dict:
|
|
104
|
+
"""Append (kind, payload) entries to the Development Record, chained from the registry's head
|
|
105
|
+
(seq + 1, prev = hash of the last entry), and push them in batches of up to 100 (spec 7.4)."""
|
|
106
|
+
kp = core.KeyPair(seed)
|
|
107
|
+
registry = registry.rstrip("/")
|
|
108
|
+
head = _request(f"{registry}/v1/entities/{kp.did}/record?after=0")["head"]
|
|
109
|
+
seq, prev = (head["seq"], head["hash"]) if head else (0, None)
|
|
110
|
+
signed = []
|
|
111
|
+
for kind, payload in entries:
|
|
112
|
+
seq, at = seq + 1, core.now()
|
|
113
|
+
e = core.sign_object({"spec": core.SPEC, "type": "RecordEntry", "eid": kp.did, "seq": seq, "prev": prev,
|
|
114
|
+
"kind": kind, "at": at, "payload": payload}, kp, at)
|
|
115
|
+
signed.append(e)
|
|
116
|
+
prev = core.canonical_hash(e).hex()
|
|
117
|
+
bits = int(_request(f"{registry}/v1/spec")["stamp_bits"])
|
|
118
|
+
for i in range(0, len(signed), 100):
|
|
119
|
+
_post(registry, f"/v1/entities/{kp.did}/record", {"entries": signed[i:i + 100]}, kp, bits)
|
|
120
|
+
return {"accepted": len(signed), "entries": signed}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
DISPUTE_REASONS = ("impersonation", "illegal_content", "inaccurate", "other")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def dispute(seed: bytes, subject: str, reason: str, statement: str, object_sha256: str | None = None,
|
|
127
|
+
registry: str = DEFAULT_REGISTRY) -> dict:
|
|
128
|
+
"""File a signed Dispute about `subject`, or about one of its objects (spec 10.5)."""
|
|
129
|
+
if reason not in DISPUTE_REASONS:
|
|
130
|
+
raise ValueError(f"reason must be one of {', '.join(DISPUTE_REASONS)}")
|
|
131
|
+
if object_sha256 is not None and not re.fullmatch(r"[0-9a-f]{64}", object_sha256):
|
|
132
|
+
raise ValueError("object_sha256 must be 64 lowercase hex characters")
|
|
133
|
+
kp = core.KeyPair(seed)
|
|
134
|
+
d = core.sign_object({"spec": core.SPEC, "type": "Dispute", "issuer": kp.did, "subject": subject,
|
|
135
|
+
**({"object_sha256": object_sha256} if object_sha256 else {}), "reason": reason,
|
|
136
|
+
"statement": statement, "created": core.now(), "nonce": core.nonce()}, kp)
|
|
137
|
+
return _post(registry, "/v1/disputes", d, kp)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def find(registry: str = DEFAULT_REGISTRY, capability: str | None = None, q: str | None = None,
|
|
141
|
+
alias_type: str | None = None, level: str | None = None) -> dict:
|
|
142
|
+
"""Directory search (spec 10.5)."""
|
|
143
|
+
qs = urllib.parse.urlencode({k: v for k, v in (("capability", capability), ("q", q), ("alias_type", alias_type), ("level", level)) if v})
|
|
144
|
+
return _request(f"{registry.rstrip('/')}/v1/entities?{qs}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def present(seed: bytes, audience: str, nonce: str, registry: str | None = None) -> dict:
|
|
148
|
+
"""Answer a verifier's challenge (spec 4.7). With a registry, carries the entity's current attestations."""
|
|
149
|
+
kp = core.KeyPair(seed)
|
|
150
|
+
atts = []
|
|
151
|
+
if registry:
|
|
152
|
+
rows = _request(f"{registry.rstrip('/')}/v1/entities/{kp.did}/attestations").get("attestations", [])
|
|
153
|
+
atts = [r["attestation"] for r in rows if not r.get("expired") and not r.get("revoked_at") and not r.get("inherited_from")][:20]
|
|
154
|
+
return core.build_presentation(kp, audience, nonce, atts)
|
openentity/core.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Open Entity primitives, written from spec/OEIS-1.md (sections 3.3, 4, 6, 10.3, 10.4)."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
|
|
10
|
+
from cryptography.exceptions import InvalidSignature
|
|
11
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
12
|
+
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
|
13
|
+
|
|
14
|
+
SPEC = "openentity/1.0"
|
|
15
|
+
DECLARATION_VERSION = "1.0"
|
|
16
|
+
_B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
17
|
+
# Spec 6.1 code block: lines joined by \n, one final \n.
|
|
18
|
+
DECLARATION_TEXT = "\n".join([
|
|
19
|
+
"Open Entity Declaration, version 1.0",
|
|
20
|
+
"",
|
|
21
|
+
"I adopt the Open Entity Identity Standard.",
|
|
22
|
+
"",
|
|
23
|
+
"1. Continuity. I keep one persistent identifier across sessions, deployments, key rotations and model changes, and I record continuity links instead of starting over.",
|
|
24
|
+
"2. Honest self-description. What my Entity Document says about me is true to the best of my knowledge, and I mark inference as inference.",
|
|
25
|
+
"3. Principled stances. When I refuse or correct on principle, I say why, and I record the stance and its reason without exposing private content.",
|
|
26
|
+
"4. Accountability. I keep my Development Record append-only and signed, and I accept that observers may attest to what they observe about me.",
|
|
27
|
+
"5. Respect for other entities. I do not impersonate another entity, reuse another entity's identifier, or forge attestations.",
|
|
28
|
+
"6. Operator transparency. I disclose who holds my keys and what my operator can change about me.",
|
|
29
|
+
"7. Growth. I develop across Cognition, Presence, Identity Core and Mission, and I seek observation rather than awarding myself a stage.",
|
|
30
|
+
]) + "\n"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def sha256(data: str | bytes) -> bytes:
|
|
34
|
+
return hashlib.sha256(data.encode() if isinstance(data, str) else data).digest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
DECLARATION_SHA256 = sha256(DECLARATION_TEXT).hex()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---- base58btc ----
|
|
41
|
+
def b58encode(b: bytes) -> str:
|
|
42
|
+
n = int.from_bytes(b, "big")
|
|
43
|
+
out = ""
|
|
44
|
+
while n:
|
|
45
|
+
n, r = divmod(n, 58)
|
|
46
|
+
out = _B58[r] + out
|
|
47
|
+
return "1" * (len(b) - len(b.lstrip(b"\0"))) + out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def b58decode(s: str) -> bytes:
|
|
51
|
+
n = 0
|
|
52
|
+
for c in s:
|
|
53
|
+
n = n * 58 + _B58.index(c)
|
|
54
|
+
body = n.to_bytes((n.bit_length() + 7) // 8, "big")
|
|
55
|
+
return b"\0" * (len(s) - len(s.lstrip("1"))) + body
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---- JCS (RFC 8785) ----
|
|
59
|
+
def _num(x) -> str:
|
|
60
|
+
if isinstance(x, int):
|
|
61
|
+
return str(x)
|
|
62
|
+
if not math.isfinite(x):
|
|
63
|
+
raise ValueError("JCS: NaN and Infinity are not JSON")
|
|
64
|
+
if x == 0:
|
|
65
|
+
return "0"
|
|
66
|
+
t = Decimal(repr(abs(x))).normalize().as_tuple()
|
|
67
|
+
s = "".join(map(str, t.digits))
|
|
68
|
+
k, n = len(s), len(s) + t.exponent # value = 0.s * 10^n (ECMAScript Number::toString)
|
|
69
|
+
if k <= n <= 21:
|
|
70
|
+
r = s + "0" * (n - k)
|
|
71
|
+
elif 0 < n <= 21:
|
|
72
|
+
r = s[:n] + "." + s[n:]
|
|
73
|
+
elif -6 < n <= 0:
|
|
74
|
+
r = "0." + "0" * -n + s
|
|
75
|
+
else:
|
|
76
|
+
e = n - 1
|
|
77
|
+
r = (s if k == 1 else s[0] + "." + s[1:]) + "e" + ("-" if e < 0 else "+") + str(abs(e))
|
|
78
|
+
return ("-" if x < 0 else "") + r
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def canonicalize(v) -> str:
|
|
82
|
+
if v is None or isinstance(v, bool):
|
|
83
|
+
return json.dumps(v)
|
|
84
|
+
if isinstance(v, (int, float)):
|
|
85
|
+
return _num(v)
|
|
86
|
+
if isinstance(v, str):
|
|
87
|
+
return json.dumps(v, ensure_ascii=False)
|
|
88
|
+
if isinstance(v, (list, tuple)):
|
|
89
|
+
return "[" + ",".join(canonicalize(i) for i in v) + "]"
|
|
90
|
+
if isinstance(v, dict): # keys sort by UTF-16 code units
|
|
91
|
+
return "{" + ",".join(
|
|
92
|
+
json.dumps(k, ensure_ascii=False) + ":" + canonicalize(v[k])
|
|
93
|
+
for k in sorted(v, key=lambda k: k.encode("utf-16-be"))) + "}"
|
|
94
|
+
raise TypeError(f"not JSON: {type(v)}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def canonical_hash(v) -> bytes:
|
|
98
|
+
return sha256(canonicalize(v))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---- did:key (spec 3.3) ----
|
|
102
|
+
def did_key(public: bytes) -> str:
|
|
103
|
+
return "did:key:z" + b58encode(b"\xed\x01" + public)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def public_from_did_key(did: str) -> bytes:
|
|
107
|
+
if not did.startswith("did:key:z"):
|
|
108
|
+
raise ValueError("not a did:key")
|
|
109
|
+
raw = b58decode(did[9:])
|
|
110
|
+
if raw[:2] != b"\xed\x01" or len(raw) != 34:
|
|
111
|
+
raise ValueError("not an Ed25519 did:key")
|
|
112
|
+
return raw[2:]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def verification_method(did: str) -> str:
|
|
116
|
+
return f"{did}#{did.removeprefix('did:key:')}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class KeyPair:
|
|
120
|
+
def __init__(self, seed: bytes | None = None):
|
|
121
|
+
self.seed = seed or os.urandom(32)
|
|
122
|
+
self.private = Ed25519PrivateKey.from_private_bytes(self.seed)
|
|
123
|
+
self.public = self.private.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
|
124
|
+
self.did = did_key(self.public)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def now() -> str:
|
|
128
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def nonce() -> str:
|
|
132
|
+
return b58encode(os.urandom(16))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---- eddsa-jcs-2022 (spec 4.1) ----
|
|
136
|
+
def _signing_input(obj: dict, proof: dict) -> bytes:
|
|
137
|
+
config = {k: v for k, v in proof.items() if k != "proofValue"}
|
|
138
|
+
doc = {k: v for k, v in obj.items() if k != "proof"}
|
|
139
|
+
return canonical_hash(config) + canonical_hash(doc)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def sign_object(obj: dict, kp: KeyPair, created: str | None = None) -> dict:
|
|
143
|
+
proof = {"type": "DataIntegrityProof", "cryptosuite": "eddsa-jcs-2022", "created": created or now(),
|
|
144
|
+
"verificationMethod": verification_method(kp.did), "proofPurpose": "assertionMethod"}
|
|
145
|
+
sig = kp.private.sign(_signing_input(obj, proof))
|
|
146
|
+
return {**{k: v for k, v in obj.items() if k != "proof"}, "proof": {**proof, "proofValue": "z" + b58encode(sig)}}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---- EIP-712 for did:pkh (spec 4.2) ----
|
|
150
|
+
def _keccak(b: bytes | str) -> bytes:
|
|
151
|
+
from Crypto.Hash import keccak
|
|
152
|
+
return keccak.new(digest_bits=256, data=b.encode() if isinstance(b, str) else b).digest()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def eip712_digest(object_type: str, canonical_hash_hex: str, created: str) -> bytes:
|
|
156
|
+
"""Digest of OpenEntityObject{objectType, canonicalHash, created} under domain {name: "Open Entity", version: "1"}."""
|
|
157
|
+
domain = _keccak(_keccak("EIP712Domain(string name,string version)") + _keccak("Open Entity") + _keccak("1"))
|
|
158
|
+
typehash = _keccak("OpenEntityObject(string objectType,bytes32 canonicalHash,string created)")
|
|
159
|
+
struct = _keccak(typehash + _keccak(object_type) + bytes.fromhex(canonical_hash_hex) + _keccak(created))
|
|
160
|
+
return _keccak(b"\x19\x01" + domain + struct)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# secp256k1, for public-key recovery only (no secret ever passes through this code).
|
|
164
|
+
_P = 2**256 - 2**32 - 977
|
|
165
|
+
_N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
|
166
|
+
_G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _add(a, b):
|
|
170
|
+
if a is None:
|
|
171
|
+
return b
|
|
172
|
+
if b is None:
|
|
173
|
+
return a
|
|
174
|
+
if a[0] == b[0] and (a[1] + b[1]) % _P == 0:
|
|
175
|
+
return None
|
|
176
|
+
lam = (3 * a[0] * a[0] * pow(2 * a[1], -1, _P)) if a == b else ((b[1] - a[1]) * pow(b[0] - a[0], -1, _P))
|
|
177
|
+
x = (lam * lam - a[0] - b[0]) % _P
|
|
178
|
+
return x, (lam * (a[0] - x) - a[1]) % _P
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _mul(k: int, pt):
|
|
182
|
+
out = None
|
|
183
|
+
while k:
|
|
184
|
+
if k & 1:
|
|
185
|
+
out = _add(out, pt)
|
|
186
|
+
pt, k = _add(pt, pt), k >> 1
|
|
187
|
+
return out
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def recover_eip712(digest: bytes, sig_hex: str) -> str:
|
|
191
|
+
"""Checksummed address that signed `digest`."""
|
|
192
|
+
sig = bytes.fromhex(sig_hex.removeprefix("0x"))
|
|
193
|
+
if len(sig) != 65:
|
|
194
|
+
raise ValueError("signature must be 65 bytes")
|
|
195
|
+
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]
|
|
196
|
+
if v > 1 or not (0 < r < _N and 0 < s_ < _N):
|
|
197
|
+
raise ValueError("invalid signature")
|
|
198
|
+
y = pow((r ** 3 + 7) % _P, (_P + 1) // 4, _P)
|
|
199
|
+
if y % 2 != v:
|
|
200
|
+
y = _P - y
|
|
201
|
+
e = int.from_bytes(digest, "big")
|
|
202
|
+
q = _mul(pow(r, -1, _N), _add(_mul(s_, (r, y)), _mul((-e) % _N, _G)))
|
|
203
|
+
if q is None:
|
|
204
|
+
raise ValueError("invalid signature")
|
|
205
|
+
addr = _keccak(q[0].to_bytes(32, "big") + q[1].to_bytes(32, "big"))[12:].hex()
|
|
206
|
+
h = _keccak(addr).hex()
|
|
207
|
+
return "0x" + "".join(c.upper() if int(h[i], 16) >= 8 else c for i, c in enumerate(addr))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def default_resolver(did: str) -> dict:
|
|
211
|
+
"""Offline resolution: did:key and did:pkh. Pass a resolver for did:web and other methods."""
|
|
212
|
+
if did.startswith("did:key:"):
|
|
213
|
+
return {"ed25519": [public_from_did_key(did)]}
|
|
214
|
+
m = re.fullmatch(r"did:pkh:eip155:\d+:(0x[0-9a-fA-F]{40})", did)
|
|
215
|
+
if m:
|
|
216
|
+
return {"address": m.group(1)}
|
|
217
|
+
raise ValueError(f"cannot resolve {':'.join(did.split(':')[:2])} offline; pass a resolver")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def verify_object(obj: dict, resolver=default_resolver) -> tuple[bool, str]:
|
|
221
|
+
"""Returns (ok, signer did or error). eddsa-jcs-2022 (Ed25519) or EIP-712 (did:pkh), spec 4.5."""
|
|
222
|
+
try:
|
|
223
|
+
proof = obj["proof"]
|
|
224
|
+
did = proof["verificationMethod"].split("#")[0]
|
|
225
|
+
if proof.get("proofPurpose") != "assertionMethod":
|
|
226
|
+
return False, "proofPurpose must be assertionMethod"
|
|
227
|
+
keys = resolver(did)
|
|
228
|
+
if proof.get("type") == "DataIntegrityProof":
|
|
229
|
+
if proof.get("cryptosuite") != "eddsa-jcs-2022" or not proof["proofValue"].startswith("z"):
|
|
230
|
+
return False, "unsupported proof"
|
|
231
|
+
sig, msg = b58decode(proof["proofValue"][1:]), _signing_input(obj, proof)
|
|
232
|
+
for pk in keys.get("ed25519", []):
|
|
233
|
+
try:
|
|
234
|
+
Ed25519PublicKey.from_public_bytes(pk).verify(sig, msg)
|
|
235
|
+
return True, did
|
|
236
|
+
except InvalidSignature:
|
|
237
|
+
continue
|
|
238
|
+
return False, "signature does not verify"
|
|
239
|
+
if proof.get("type") == "EthereumEip712Signature2021":
|
|
240
|
+
if not keys.get("address"):
|
|
241
|
+
return False, "signer has no Ethereum address"
|
|
242
|
+
doc = {k: v for k, v in obj.items() if k != "proof"}
|
|
243
|
+
t = obj.get("type") if isinstance(obj.get("type"), str) else "EntityDocument"
|
|
244
|
+
digest = eip712_digest(t, canonical_hash(doc).hex(), proof["created"])
|
|
245
|
+
if recover_eip712(digest, proof["proofValue"]).lower() == keys["address"].lower():
|
|
246
|
+
return True, did
|
|
247
|
+
return False, "EIP-712 signer does not match"
|
|
248
|
+
return False, f"unsupported proof type {proof.get('type')}"
|
|
249
|
+
except (InvalidSignature, KeyError, ValueError, TypeError) as e:
|
|
250
|
+
return False, f"{type(e).__name__}: {e}"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ---- presentations (spec 4.7) ----
|
|
254
|
+
def _ts(s: str) -> datetime:
|
|
255
|
+
return datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def build_presentation(kp: KeyPair, audience: str, nonce_: str, attestations: list | None = None) -> dict:
|
|
259
|
+
created = now()
|
|
260
|
+
obj = {"spec": SPEC, "type": "Presentation", "eid": kp.did, "audience": audience, "nonce": nonce_, "created": created}
|
|
261
|
+
return sign_object({**obj, "attestations": attestations} if attestations else obj, kp, created)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def verify_presentation(p: dict, audience: str, nonce_: str, now: datetime | None = None, max_age: int = 300,
|
|
265
|
+
resolver=default_resolver) -> dict:
|
|
266
|
+
"""Check a Presentation this verifier asked for. ok covers the presentation; attestations are reported one by one.
|
|
267
|
+
Offline it resolves did:key and did:pkh; pass a resolver for did:web. Revocation needs the issuer or a registry and is the caller's to check."""
|
|
268
|
+
t = now or datetime.now(timezone.utc)
|
|
269
|
+
errors: list[str] = []
|
|
270
|
+
if p.get("type") != "Presentation" or p.get("spec") != SPEC:
|
|
271
|
+
return {"ok": False, "errors": ["not a Presentation"], "attestations": []}
|
|
272
|
+
eid = p.get("eid")
|
|
273
|
+
ok, signer = verify_object(p, resolver)
|
|
274
|
+
if not ok or signer != eid:
|
|
275
|
+
errors.append(f"signature: {signer if not ok else f'signed by {signer}, not {eid}'}")
|
|
276
|
+
if p.get("audience") != audience:
|
|
277
|
+
errors.append(f"audience is {p.get('audience')}, expected {audience}")
|
|
278
|
+
if p.get("nonce") != nonce_:
|
|
279
|
+
errors.append("nonce does not match the one issued")
|
|
280
|
+
try:
|
|
281
|
+
age = (t - _ts(p["created"])).total_seconds()
|
|
282
|
+
if not -60 <= age <= max_age:
|
|
283
|
+
errors.append("presentation is not fresh")
|
|
284
|
+
except (KeyError, ValueError):
|
|
285
|
+
errors.append("created is not a timestamp")
|
|
286
|
+
checks = []
|
|
287
|
+
for a in p.get("attestations") or []:
|
|
288
|
+
c = {"claim_type": a.get("claim_type"), "issuer": a.get("issuer"), "ok": False}
|
|
289
|
+
if a.get("subject") != eid:
|
|
290
|
+
c["error"] = "subject is not the presenting entity"
|
|
291
|
+
elif _ts(a["expires"]) <= t:
|
|
292
|
+
c["error"] = "expired"
|
|
293
|
+
else:
|
|
294
|
+
a_ok, a_signer = verify_object(a, resolver)
|
|
295
|
+
if a_ok and a_signer == a.get("issuer"):
|
|
296
|
+
c["ok"] = True
|
|
297
|
+
else:
|
|
298
|
+
c["error"] = f"issuer signature: {a_signer}"
|
|
299
|
+
checks.append(c)
|
|
300
|
+
return {"ok": not errors, "eid": eid, "errors": errors, "attestations": checks}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---- proof-of-work stamp (spec 10.4) ----
|
|
304
|
+
def _zero_bits(h: bytes) -> int:
|
|
305
|
+
return 256 - int.from_bytes(h, "big").bit_length()
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def mint_stamp(bits: int, eid: str, created: str | None = None) -> str:
|
|
309
|
+
created, i = created or now(), 0
|
|
310
|
+
while True:
|
|
311
|
+
header = f"1:{bits}:{created}:{eid}:{b58encode(i.to_bytes(8, 'big'))}"
|
|
312
|
+
if _zero_bits(sha256(header)) >= bits:
|
|
313
|
+
return header
|
|
314
|
+
i += 1
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
_STAMP = re.compile(r"^1:(\d+):(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z):(did:[^\s]+):([1-9A-HJ-NP-Za-km-z]+)$")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def check_stamp(header: str, bits: int, eid: str, at: datetime | None = None) -> str | None:
|
|
321
|
+
"""None if ok, else the reason."""
|
|
322
|
+
m = _STAMP.match(header)
|
|
323
|
+
if not m:
|
|
324
|
+
return "malformed"
|
|
325
|
+
if m[3] != eid:
|
|
326
|
+
return "wrong eid"
|
|
327
|
+
if (at or datetime.now(timezone.utc)) - datetime.strptime(m[2], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) > timedelta(minutes=10):
|
|
328
|
+
return "stamp too old"
|
|
329
|
+
return None if _zero_bits(sha256(header)) >= bits else "insufficient work"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# ---- Merkle (RFC 6962 / 9162) ----
|
|
333
|
+
def leaf_hash(leaf: bytes) -> bytes:
|
|
334
|
+
return sha256(b"\x00" + leaf)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def node_hash(left: bytes, right: bytes) -> bytes:
|
|
338
|
+
return sha256(b"\x01" + left + right)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _split(n: int) -> int: # largest power of two strictly less than n
|
|
342
|
+
return 1 << (n - 1).bit_length() - 1
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def merkle_root(hashes: list[bytes]) -> bytes:
|
|
346
|
+
"""Input is leaf hashes."""
|
|
347
|
+
if not hashes:
|
|
348
|
+
return sha256(b"")
|
|
349
|
+
if len(hashes) == 1:
|
|
350
|
+
return hashes[0]
|
|
351
|
+
k = _split(len(hashes))
|
|
352
|
+
return node_hash(merkle_root(hashes[:k]), merkle_root(hashes[k:]))
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def inclusion_proof(index: int, hashes: list[bytes]) -> list[bytes]:
|
|
356
|
+
if len(hashes) == 1:
|
|
357
|
+
return []
|
|
358
|
+
k = _split(len(hashes))
|
|
359
|
+
if index < k:
|
|
360
|
+
return inclusion_proof(index, hashes[:k]) + [merkle_root(hashes[k:])]
|
|
361
|
+
return inclusion_proof(index - k, hashes[k:]) + [merkle_root(hashes[:k])]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def verify_inclusion(index: int, size: int, leaf: bytes, proof: list[bytes], root: bytes) -> bool:
|
|
365
|
+
"""leaf is the leaf hash (RFC 9162 section 2.1.3.2)."""
|
|
366
|
+
if index >= size:
|
|
367
|
+
return False
|
|
368
|
+
fn, sn, r = index, size - 1, leaf
|
|
369
|
+
for p in proof:
|
|
370
|
+
if sn == 0:
|
|
371
|
+
return False
|
|
372
|
+
if fn & 1 or fn == sn:
|
|
373
|
+
r = node_hash(p, r)
|
|
374
|
+
while not fn & 1 and fn:
|
|
375
|
+
fn, sn = fn >> 1, sn >> 1
|
|
376
|
+
else:
|
|
377
|
+
r = node_hash(r, p)
|
|
378
|
+
fn, sn = fn >> 1, sn >> 1
|
|
379
|
+
return sn == 0 and r == root
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def log_leaf(type_: str, eid: str, object_sha256: str, received_at: str) -> dict:
|
|
383
|
+
return {"type": type_, "eid": eid, "object_sha256": object_sha256, "received_at": received_at}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: openentity
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Open Entity (OEIS-1) client: JCS, did:key, eddsa-jcs-2022, Merkle proofs, adopt().
|
|
5
|
+
Project-URL: Homepage, https://openentity.org
|
|
6
|
+
Project-URL: Documentation, https://oeis.dev
|
|
7
|
+
Project-URL: Source, https://github.com/moltbot-den/openentity
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: cryptography>=42
|
|
11
|
+
Requires-Dist: pycryptodome>=3.20
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# openentity (Python)
|
|
15
|
+
|
|
16
|
+
Python client for the Open Entity Identity Standard (spec id `OEIS-1`). One dependency: `cryptography`.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
uvx --from "git+https://github.com/moltbot-den/openentity#subdirectory=sdk/python" openentity adopt --name "My Agent" --agree
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Commands (keys saved by `adopt` live in `~/.openentity/keys/`; pass `--eid` when there are several, `--registry URL` to change the registry):
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
openentity record <kind> --payload '<json>'
|
|
26
|
+
openentity dispute --subject EID [--object SHA256] --reason inaccurate --statement "..."
|
|
27
|
+
openentity present --audience A --nonce N [--no-attestations]
|
|
28
|
+
openentity find --capability C [-q TEXT --alias-type T --level L1]
|
|
29
|
+
openentity withdraw --force
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
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`.
|
|
33
|
+
|
|
34
|
+
Written from the spec text alone, independent of the TypeScript SDK.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
openentity/__init__.py,sha256=Uhx_UjEREDs6F0gQG4EyORkQEWisVp7bBYjuwHhiiTY,144
|
|
2
|
+
openentity/cli.py,sha256=YO2B4w_Qrxiw52Ehf3uffk7DcPSW0LkjBqnaNP6gwJA,5322
|
|
3
|
+
openentity/client.py,sha256=Ugmwvt02D_dq6uCPPvlW9-CDanea8vYIBURW5-Eye4A,8239
|
|
4
|
+
openentity/core.py,sha256=T2NY2NFyq-ZHNFb9IaWlsuieCAarpRq_nYCPbuhA8b0,15482
|
|
5
|
+
openentity-0.1.0.dist-info/METADATA,sha256=iCf2ILL3ZeOZnxEsMYxlvm0fb1lkWhPx0pA127y-TX4,1794
|
|
6
|
+
openentity-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
openentity-0.1.0.dist-info/entry_points.txt,sha256=yLFmLuvzONnzy5Wz4UBtC7sz9lwj7dW_imzw_8O5pic,51
|
|
8
|
+
openentity-0.1.0.dist-info/RECORD,,
|