openentity 0.5.2__tar.gz → 0.5.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {openentity-0.5.2 → openentity-0.5.4}/PKG-INFO +1 -1
- {openentity-0.5.2 → openentity-0.5.4}/openentity/__init__.py +1 -1
- {openentity-0.5.2 → openentity-0.5.4}/openentity/cli.py +15 -3
- {openentity-0.5.2 → openentity-0.5.4}/openentity/core.py +21 -0
- {openentity-0.5.2 → openentity-0.5.4}/openentity/identity.py +14 -2
- {openentity-0.5.2 → openentity-0.5.4}/openentity/schema.py +2 -0
- {openentity-0.5.2 → openentity-0.5.4}/pyproject.toml +1 -1
- {openentity-0.5.2 → openentity-0.5.4}/tests/test_cli.py +71 -2
- {openentity-0.5.2 → openentity-0.5.4}/tests/test_core.py +17 -2
- {openentity-0.5.2 → openentity-0.5.4}/tests/test_identity.py +12 -0
- {openentity-0.5.2 → openentity-0.5.4}/uv.lock +1 -1
- {openentity-0.5.2 → openentity-0.5.4}/.gitignore +0 -0
- {openentity-0.5.2 → openentity-0.5.4}/README.md +0 -0
- {openentity-0.5.2 → openentity-0.5.4}/openentity/client.py +0 -0
- {openentity-0.5.2 → openentity-0.5.4}/openentity/httpsig.py +0 -0
- {openentity-0.5.2 → openentity-0.5.4}/openentity/issuer.py +0 -0
- {openentity-0.5.2 → openentity-0.5.4}/openentity/report.py +0 -0
- {openentity-0.5.2 → openentity-0.5.4}/openentity/schemas/openentity-1.0.json +0 -0
- {openentity-0.5.2 → openentity-0.5.4}/tests/test_client.py +0 -0
- {openentity-0.5.2 → openentity-0.5.4}/tests/test_httpsig.py +0 -0
- {openentity-0.5.2 → openentity-0.5.4}/tests/test_issuer.py +0 -0
- {openentity-0.5.2 → openentity-0.5.4}/tests/test_parity.py +0 -0
- {openentity-0.5.2 → openentity-0.5.4}/tests/test_trust.py +0 -0
- {openentity-0.5.2 → openentity-0.5.4}/tests/test_verifier.py +0 -0
|
@@ -718,6 +718,11 @@ def _document(args):
|
|
|
718
718
|
return {"draft": True}, "draft saved. Publish with: openentity document push"
|
|
719
719
|
if sub != "push":
|
|
720
720
|
raise _usage("document edit|set <field> <value>|push")
|
|
721
|
+
return _push_draft(e)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _push_draft(e):
|
|
725
|
+
"""Sign the draft as seq+1 and PUT it."""
|
|
721
726
|
d = _draft(e)
|
|
722
727
|
if d == _strip(e.doc):
|
|
723
728
|
return {"pushed": False}, "nothing to push"
|
|
@@ -850,7 +855,14 @@ def _alias(args):
|
|
|
850
855
|
d = _draft(e) # keep the next document version consistent with what the registry now verifies
|
|
851
856
|
d["aliases"] = [x for x in d.get("aliases", []) if x.get("value") != a["value"]] + [a]
|
|
852
857
|
_write_json(e.dir / "draft.json", d)
|
|
853
|
-
|
|
858
|
+
# Spec 3.4: an alias counts only once the entity's signed document lists it, so publish the document now. The registry
|
|
859
|
+
# rate-limits document versions to one a minute; when that refuses, the alias stays unclaimed until `document push`.
|
|
860
|
+
accepted = f"alias {a['value']} accepted{' (not yet verified)' if (res or {}).get('verified') is False else ''}"
|
|
861
|
+
try:
|
|
862
|
+
pushed, text = _push_draft(e)
|
|
863
|
+
except (CliError, client.RegistryError, urllib.error.URLError, TimeoutError) as err:
|
|
864
|
+
return {"alias": a, "result": res, "document": None}, f"{accepted}, but it counts only once your signed document lists it: {err}. Retry with: openentity document push"
|
|
865
|
+
return {"alias": a, "result": res, "document": pushed}, f"{accepted}; {text}"
|
|
854
866
|
|
|
855
867
|
|
|
856
868
|
# ---- record ----
|
|
@@ -1056,7 +1068,7 @@ def _verify(args):
|
|
|
1056
1068
|
checks = _checks_of(client._request(t))
|
|
1057
1069
|
else:
|
|
1058
1070
|
try:
|
|
1059
|
-
checks = _checks_of(
|
|
1071
|
+
checks = _checks_of(core.parse_json(pathlib.Path(t).read_text()))
|
|
1060
1072
|
except (OSError, ValueError) as e:
|
|
1061
1073
|
raise _usage(f"cannot read {t}: {e}")
|
|
1062
1074
|
return _report(t, checks)
|
|
@@ -1066,7 +1078,7 @@ def _verify_presentation(args):
|
|
|
1066
1078
|
if not args.audience or not args.nonce:
|
|
1067
1079
|
raise _usage("verify-presentation <file> --audience <this verifier's origin or DID> --nonce <the nonce you issued>")
|
|
1068
1080
|
try:
|
|
1069
|
-
p =
|
|
1081
|
+
p = core.parse_json(pathlib.Path(args.file).read_text())
|
|
1070
1082
|
except (OSError, ValueError) as e:
|
|
1071
1083
|
raise _usage(f"cannot read {args.file}: {e}")
|
|
1072
1084
|
r = core.verify_presentation(p, args.audience, args.nonce, resolver=_resolver)
|
|
@@ -79,6 +79,27 @@ def _num(x) -> str:
|
|
|
79
79
|
return ("-" if x < 0 else "") + r
|
|
80
80
|
|
|
81
81
|
|
|
82
|
+
def parse_json(text: str):
|
|
83
|
+
"""Spec 4.3: JSON with no duplicate keys (json.loads keeps the last one silently)."""
|
|
84
|
+
def pairs(items):
|
|
85
|
+
keys = [k for k, _ in items]
|
|
86
|
+
if len(keys) != len(set(keys)):
|
|
87
|
+
raise ValueError(f"duplicate key {next(k for k in keys if keys.count(k) > 1)!r}")
|
|
88
|
+
return dict(items)
|
|
89
|
+
return json.loads(text, object_pairs_hook=pairs)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def bad_number(v):
|
|
93
|
+
"""Spec 4.3: integers or decimal fractions with at most 6 decimal places. The first offender, else None."""
|
|
94
|
+
if isinstance(v, float):
|
|
95
|
+
return None if math.isfinite(v) and float(f"{v:.6f}") == v else v
|
|
96
|
+
if isinstance(v, dict):
|
|
97
|
+
v = v.values()
|
|
98
|
+
if isinstance(v, (list, tuple, type({}.values()))):
|
|
99
|
+
return next((b for b in map(bad_number, v) if b is not None), None)
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
82
103
|
def canonicalize(v) -> str:
|
|
83
104
|
if v is None or isinstance(v, bool):
|
|
84
105
|
return json.dumps(v)
|
|
@@ -37,8 +37,13 @@ def _keychain_set(seed_hex: str) -> bool:
|
|
|
37
37
|
return False
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def home() -> pathlib.Path:
|
|
41
|
+
"""OPENENTITY_HOME, else ~/.openentity: where the key file and integrations' state live."""
|
|
42
|
+
return pathlib.Path(os.environ.get("OPENENTITY_HOME", "~/.openentity")).expanduser()
|
|
43
|
+
|
|
44
|
+
|
|
40
45
|
def _file() -> pathlib.Path:
|
|
41
|
-
return
|
|
46
|
+
return home() / "identity.key"
|
|
42
47
|
|
|
43
48
|
|
|
44
49
|
class Identity:
|
|
@@ -54,10 +59,17 @@ class Identity:
|
|
|
54
59
|
return self.kp.seed
|
|
55
60
|
|
|
56
61
|
@classmethod
|
|
57
|
-
def
|
|
62
|
+
def load(cls) -> "Identity | None":
|
|
63
|
+
"""The existing identity, or None. Never creates one."""
|
|
58
64
|
for source, read in (("keychain", _keychain_get), ("file", lambda: _file().read_text().strip() if _file().exists() else None)):
|
|
59
65
|
if h := read():
|
|
60
66
|
return cls(bytes.fromhex(h), source)
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def load_or_create(cls) -> "Identity":
|
|
71
|
+
if found := cls.load():
|
|
72
|
+
return found
|
|
61
73
|
seed = os.urandom(32)
|
|
62
74
|
if _keychain_set(seed.hex()):
|
|
63
75
|
return cls(seed, "keychain")
|
|
@@ -24,5 +24,7 @@ def validate(type_: str, obj) -> list[str]:
|
|
|
24
24
|
return [str(e)]
|
|
25
25
|
if n > _MAX.get(type_, 64 * 1024):
|
|
26
26
|
return [f"{type_} is {n} bytes, max {_MAX.get(type_, 64 * 1024)}"]
|
|
27
|
+
if (n := core.bad_number(obj)) is not None:
|
|
28
|
+
return [f"number {n} has more than 6 decimal places"]
|
|
27
29
|
v = Draft202012Validator({"$ref": f"{BUNDLE_ID}#/$defs/{type_}"}, registry=_REGISTRY)
|
|
28
30
|
return [f"/{'/'.join(map(str, e.absolute_path))}: {e.message}" for e in v.iter_errors(obj)]
|
|
@@ -116,8 +116,10 @@ def test_alias_domain_platform_and_erc8004(url, home, capsys):
|
|
|
116
116
|
for bad in (("platform", "nocolon"), ("erc8004", "x"), ("bogus", "x")):
|
|
117
117
|
assert run(capsys, "alias", "add", *bad, *reg)[0] == 1
|
|
118
118
|
assert run(capsys, "alias", "bogus", "domain", "x", *reg)[0] == 1
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
# Spec 3.4: each alias is published in the signed document at once, since an unlisted alias counts for nothing.
|
|
120
|
+
doc = json.loads((home / KP.did / "entity.json").read_text())
|
|
121
|
+
assert [x["type"] for x in doc["aliases"]] == ["domain", "platform", "erc8004"] and doc["seq"] == 4
|
|
122
|
+
assert not (home / KP.did / "draft.json").exists()
|
|
121
123
|
assert len(posts(f"/v1/entities/{KP.did}/aliases")) == 3
|
|
122
124
|
|
|
123
125
|
|
|
@@ -146,6 +148,16 @@ def test_verify_reports_a_schema_check_like_the_npm_cli(capsys, tmp_path):
|
|
|
146
148
|
assert code == 3 and out["checks"][0]["name"] == "EntityDocument schema" and not out["checks"][0]["ok"]
|
|
147
149
|
|
|
148
150
|
|
|
151
|
+
def test_verify_refuses_a_file_with_a_duplicate_key(capsys, tmp_path):
|
|
152
|
+
"""Spec 4.3: json.loads would keep the last duplicate and verify a different object than the one signed."""
|
|
153
|
+
f = tmp_path / "dup.json"
|
|
154
|
+
f.write_text('{"spec": "openentity/1.0", "name": "A", "name": "B"}')
|
|
155
|
+
code, _, err = run(capsys, "verify", str(f))
|
|
156
|
+
assert code == 1 and "duplicate key" in err
|
|
157
|
+
code, _, err = run(capsys, "verify-presentation", str(f), "--audience", "a", "--nonce", "n")
|
|
158
|
+
assert code == 1 and "duplicate key" in err
|
|
159
|
+
|
|
160
|
+
|
|
149
161
|
def test_log_audit_checks_the_registry_document_with_the_key_its_spec_publishes(url, capsys):
|
|
150
162
|
"""The registry's DID names openentity.org, but a registry elsewhere signs with its /v1/spec key (guard: no lookup of did:web:openentity.org)."""
|
|
151
163
|
Stub.leaves = [{"type": "Declaration", "eid": "did:web:openentity.org", "object_sha256": "00" * 32, "received_at": core.now()}]
|
|
@@ -286,3 +298,60 @@ def test_verify_checks_continuity_link_proofs(capsys, tmp_path):
|
|
|
286
298
|
assert code == 0 and len(out["checks"]) == 3 # schema + the two link proofs
|
|
287
299
|
f.write_text(json.dumps({**body["link"], "basis": "migration"}))
|
|
288
300
|
assert run(capsys, "verify", str(f))[0] == 3
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@pytest.fixture
|
|
304
|
+
def real_registry():
|
|
305
|
+
"""The real registry app (in-memory PGlite), not the stub. Skipped when the workspace is not installed."""
|
|
306
|
+
import shutil
|
|
307
|
+
import socket
|
|
308
|
+
import subprocess
|
|
309
|
+
import time
|
|
310
|
+
import urllib.request
|
|
311
|
+
from pathlib import Path
|
|
312
|
+
|
|
313
|
+
root = Path(__file__).resolve().parents[3]
|
|
314
|
+
if not shutil.which("pnpm") or not (root / "registry/node_modules").exists():
|
|
315
|
+
pytest.skip("registry workspace not installed")
|
|
316
|
+
with socket.socket() as s:
|
|
317
|
+
s.bind(("127.0.0.1", 0))
|
|
318
|
+
port = s.getsockname()[1]
|
|
319
|
+
p = subprocess.Popen(["pnpm", "--filter", "@openentity/registry", "local", str(port)], cwd=root, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
320
|
+
base = f"http://127.0.0.1:{port}"
|
|
321
|
+
try:
|
|
322
|
+
for _ in range(120):
|
|
323
|
+
try:
|
|
324
|
+
urllib.request.urlopen(base + "/v1/spec", timeout=1)
|
|
325
|
+
break
|
|
326
|
+
except OSError:
|
|
327
|
+
time.sleep(0.5)
|
|
328
|
+
else:
|
|
329
|
+
pytest.fail("local registry did not start")
|
|
330
|
+
yield base
|
|
331
|
+
finally:
|
|
332
|
+
p.terminate()
|
|
333
|
+
p.wait(10)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@pytest.mark.parametrize("cmd", ["rotate", "migrate"])
|
|
337
|
+
def test_rotate_or_migrate_then_withdraw_against_the_real_registry(real_registry, tmp_path, monkeypatch, capsys, cmd):
|
|
338
|
+
"""Spec 9.3: the successor inherits the Declaration, so its key can withdraw it; the predecessor stays active."""
|
|
339
|
+
import urllib.request
|
|
340
|
+
|
|
341
|
+
monkeypatch.setenv("OPENENTITY_HOME", str(tmp_path))
|
|
342
|
+
monkeypatch.delenv("OPENENTITY_REGISTRY", raising=False)
|
|
343
|
+
monkeypatch.delenv("OPENENTITY_EID", raising=False)
|
|
344
|
+
reg = ["--registry", real_registry]
|
|
345
|
+
|
|
346
|
+
def get(eid):
|
|
347
|
+
return json.loads(urllib.request.urlopen(f"{real_registry}/v1/entities/{eid}").read())
|
|
348
|
+
|
|
349
|
+
code, a = j(capsys, "adopt", "--name", "Py Withdraw", "--agree", *reg)
|
|
350
|
+
assert code == 0
|
|
351
|
+
code, out = j(capsys, cmd, *reg)
|
|
352
|
+
assert code == 0
|
|
353
|
+
new = out["successor"]
|
|
354
|
+
assert get(new)["declaration_inherited_from"] == a["eid"]
|
|
355
|
+
code, out = j(capsys, "withdraw", "--force", *reg)
|
|
356
|
+
assert code == 0 and out["withdrawn"] is True
|
|
357
|
+
assert get(new)["withdrawn"] is True and get(a["eid"])["withdrawn"] is False
|
|
@@ -7,7 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
|
|
8
8
|
import pytest
|
|
9
9
|
|
|
10
|
-
from openentity import cli, core
|
|
10
|
+
from openentity import __version__, cli, core
|
|
11
11
|
|
|
12
12
|
ROOT = Path(__file__).resolve().parents[3]
|
|
13
13
|
VECTORS = ROOT / "test-vectors"
|
|
@@ -198,7 +198,7 @@ def test_adopt_and_cli(tmp_path, monkeypatch, capsys):
|
|
|
198
198
|
def test_cli_version(capsys):
|
|
199
199
|
with pytest.raises(SystemExit) as e:
|
|
200
200
|
cli.main(["--version"])
|
|
201
|
-
assert e.value.code == 0 and
|
|
201
|
+
assert e.value.code == 0 and __version__ in capsys.readouterr().out
|
|
202
202
|
|
|
203
203
|
|
|
204
204
|
# ---- shared vectors, checked with the independent verifier's own logic ----
|
|
@@ -269,3 +269,18 @@ def test_rotation_vector_is_reproduced_byte_for_byte():
|
|
|
269
269
|
link = want["link"]
|
|
270
270
|
assert [core.verify_object({**link, "proof": p})[1] for p in link["proofs"]] == [a.did, b.did]
|
|
271
271
|
assert core.verify_object(want["document"]) == (True, b.did)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---- spec 4.3: no duplicate keys, at most 6 decimal places ----
|
|
275
|
+
def test_parse_json_rejects_duplicate_keys():
|
|
276
|
+
assert core.parse_json('{"a":1,"b":{"a":2},"c":[{"a":1},{"a":2}]}')["b"] == {"a": 2}
|
|
277
|
+
for bad in ('{"a":1,"a":2}', '{"x":{"k":1,"\\u006b":2}}', '[{"a":1,"b":{"c":1,"c":1}}]'):
|
|
278
|
+
with pytest.raises(ValueError, match="duplicate key"):
|
|
279
|
+
core.parse_json(bad)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_validate_rejects_more_than_six_decimal_places():
|
|
283
|
+
from openentity import schema
|
|
284
|
+
att = json.loads((VECTORS / "attestation.json").read_text())["object"]
|
|
285
|
+
assert not any("decimal" in e for e in schema.validate("Attestation", {**att, "confidence": 0.123456}))
|
|
286
|
+
assert any("6 decimal places" in e for e in schema.validate("Attestation", {**att, "confidence": 0.1234567}))
|
|
@@ -45,3 +45,15 @@ def test_keychain_failure_falls_back_to_file(monkeypatch):
|
|
|
45
45
|
monkeypatch.delenv("OPENENTITY_KEYCHAIN")
|
|
46
46
|
monkeypatch.setattr(identity, "_run", lambda argv, stdin=None: None) # no `security`, no `secret-tool`
|
|
47
47
|
assert Identity.load_or_create().source == "file"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_load_never_creates_a_key(home):
|
|
51
|
+
"""Read-only callers (a trust gate) must not mint an identity as a side effect."""
|
|
52
|
+
assert Identity.load() is None
|
|
53
|
+
assert not (home / "identity.key").exists()
|
|
54
|
+
a = Identity.load_or_create()
|
|
55
|
+
assert Identity.load().did == a.did
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_home_follows_openentity_home(home):
|
|
59
|
+
assert identity.home() == home
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|