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.
Files changed (24) hide show
  1. {openentity-0.5.2 → openentity-0.5.4}/PKG-INFO +1 -1
  2. {openentity-0.5.2 → openentity-0.5.4}/openentity/__init__.py +1 -1
  3. {openentity-0.5.2 → openentity-0.5.4}/openentity/cli.py +15 -3
  4. {openentity-0.5.2 → openentity-0.5.4}/openentity/core.py +21 -0
  5. {openentity-0.5.2 → openentity-0.5.4}/openentity/identity.py +14 -2
  6. {openentity-0.5.2 → openentity-0.5.4}/openentity/schema.py +2 -0
  7. {openentity-0.5.2 → openentity-0.5.4}/pyproject.toml +1 -1
  8. {openentity-0.5.2 → openentity-0.5.4}/tests/test_cli.py +71 -2
  9. {openentity-0.5.2 → openentity-0.5.4}/tests/test_core.py +17 -2
  10. {openentity-0.5.2 → openentity-0.5.4}/tests/test_identity.py +12 -0
  11. {openentity-0.5.2 → openentity-0.5.4}/uv.lock +1 -1
  12. {openentity-0.5.2 → openentity-0.5.4}/.gitignore +0 -0
  13. {openentity-0.5.2 → openentity-0.5.4}/README.md +0 -0
  14. {openentity-0.5.2 → openentity-0.5.4}/openentity/client.py +0 -0
  15. {openentity-0.5.2 → openentity-0.5.4}/openentity/httpsig.py +0 -0
  16. {openentity-0.5.2 → openentity-0.5.4}/openentity/issuer.py +0 -0
  17. {openentity-0.5.2 → openentity-0.5.4}/openentity/report.py +0 -0
  18. {openentity-0.5.2 → openentity-0.5.4}/openentity/schemas/openentity-1.0.json +0 -0
  19. {openentity-0.5.2 → openentity-0.5.4}/tests/test_client.py +0 -0
  20. {openentity-0.5.2 → openentity-0.5.4}/tests/test_httpsig.py +0 -0
  21. {openentity-0.5.2 → openentity-0.5.4}/tests/test_issuer.py +0 -0
  22. {openentity-0.5.2 → openentity-0.5.4}/tests/test_parity.py +0 -0
  23. {openentity-0.5.2 → openentity-0.5.4}/tests/test_trust.py +0 -0
  24. {openentity-0.5.2 → openentity-0.5.4}/tests/test_verifier.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: openentity
3
- Version: 0.5.2
3
+ Version: 0.5.4
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
@@ -5,4 +5,4 @@ from .httpsig import HttpSigError, sign_request, verify_request # noqa: F401
5
5
  from .identity import Identity # noqa: F401
6
6
  from .report import TrustReport, trust # noqa: F401
7
7
 
8
- __version__ = "0.5.2"
8
+ __version__ = "0.5.4"
@@ -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
- return {"alias": a, "result": res}, f"alias {a['value']} accepted{' (not yet verified)' if (res or {}).get('verified') is False else ''}. Add it to your signed document with: openentity document push"
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(json.loads(pathlib.Path(t).read_text()))
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 = json.loads(pathlib.Path(args.file).read_text())
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 pathlib.Path(os.environ.get("OPENENTITY_HOME", "~/.openentity")).expanduser() / "identity.key"
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 load_or_create(cls) -> "Identity":
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)]
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openentity"
7
- version = "0.5.2"
7
+ version = "0.5.4"
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"
@@ -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
- draft = json.loads((home / KP.did / "draft.json").read_text())
120
- assert [x["type"] for x in draft["aliases"]] == ["domain", "platform", "erc8004"]
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 "0.5.2" in capsys.readouterr().out
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
@@ -699,7 +699,7 @@ wheels = [
699
699
 
700
700
  [[package]]
701
701
  name = "openentity"
702
- version = "0.5.2"
702
+ version = "0.5.4"
703
703
  source = { editable = "." }
704
704
  dependencies = [
705
705
  { name = "cryptography" },
File without changes
File without changes