openentity 0.2.0__tar.gz → 0.3.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: openentity
3
- Version: 0.2.0
3
+ Version: 0.3.1
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
@@ -37,7 +37,7 @@ openentity find --capability C [-q TEXT --alias-type T --level L1]
37
37
  openentity withdraw --force
38
38
  ```
39
39
 
40
- Exit codes match the npm CLI: 0 ok, 1 usage, 2 network, 3 verification failed, 4 registry rejected, 5 local state. `verify` checks signatures, the signer, the Declaration hash and the record chain, not the log checkpoint (use the npm CLI for that).
40
+ Exit codes match the npm CLI: 0 ok, 1 usage, 2 network, 3 verification failed, 4 registry rejected, 5 local state. `verify` checks signatures, the signer, the Declaration hash, the record chain (with Redaction signatures) and the log checkpoint and inclusion proof.
41
41
 
42
42
  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`.
43
43
 
@@ -24,7 +24,7 @@ openentity find --capability C [-q TEXT --alias-type T --level L1]
24
24
  openentity withdraw --force
25
25
  ```
26
26
 
27
- Exit codes match the npm CLI: 0 ok, 1 usage, 2 network, 3 verification failed, 4 registry rejected, 5 local state. `verify` checks signatures, the signer, the Declaration hash and the record chain, not the log checkpoint (use the npm CLI for that).
27
+ Exit codes match the npm CLI: 0 ok, 1 usage, 2 network, 3 verification failed, 4 registry rejected, 5 local state. `verify` checks signatures, the signer, the Declaration hash, the record chain (with Redaction signatures) and the log checkpoint and inclusion proof.
28
28
 
29
29
  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`.
30
30
 
@@ -0,0 +1,4 @@
1
+ from .client import adopt, dispute, find, present, record, rotate, withdraw # noqa: F401
2
+ from .core import * # noqa: F401,F403
3
+
4
+ __version__ = "0.3.1"
@@ -2,6 +2,7 @@ import argparse
2
2
  import json
3
3
  import os
4
4
  import pathlib
5
+ import re
5
6
  import sys
6
7
  import urllib.error
7
8
 
@@ -121,7 +122,7 @@ def _parser() -> argparse.ArgumentParser:
121
122
  p.add_argument("--audience", required=True)
122
123
  p.add_argument("--nonce", required=True)
123
124
  p.add_argument("--no-attestations", action="store_true")
124
- v = add("verify", "verify an EID, URL or file: signatures, signer, Declaration hash, record chain", key=False)
125
+ v = add("verify", "verify an EID, URL or file: signatures, signer, Declaration hash, record chain, log checkpoint and inclusion", key=False)
125
126
  v.add_argument("target")
126
127
  w = add("withdraw", "withdraw the Declaration (cannot be undone)")
127
128
  w.add_argument("--force", action="store_true")
@@ -220,7 +221,14 @@ def _chain_checks(entries: list[dict], eid: str) -> list[dict]:
220
221
  why.append("seq breaks the sequence")
221
222
  if e.get("prev") != ((prev.get("hash") if prev.get("redacted") else core.canonical_hash(prev).hex()) if prev else None):
222
223
  why.append("prev hash breaks the chain")
223
- if not e.get("redacted"):
224
+ if e.get("redacted"):
225
+ if not re.fullmatch(r"[0-9a-f]{64}", e.get("hash") or ""):
226
+ why.append("placeholder hash is not SHA-256 hex")
227
+ if r := e.get("redaction"): # the entity's signed request for this entry
228
+ ok, who = core.verify_object(r)
229
+ if not (ok and who == eid and r.get("eid") == eid and r.get("seq") == e["seq"] and r.get("entry_sha256") == e.get("hash")):
230
+ why.append("redaction is not the entity's signed request for this entry")
231
+ else:
224
232
  if e.get("eid") != eid:
225
233
  why.append("eid mismatch")
226
234
  ok, err = core.verify_object(e)
@@ -232,7 +240,27 @@ def _chain_checks(entries: list[dict], eid: str) -> list[dict]:
232
240
  return bad + [{"name": f"record chain ({len(entries)} entries)", "ok": not bad}]
233
241
 
234
242
 
235
- # ponytail: no checkpoint or log-inclusion check here; `npx openentity verify <eid>` does those.
243
+ def _log_checks(reg: str, index: int | None) -> list[dict]:
244
+ """Checkpoint note signature against the registry key from /v1/spec, then the RFC 9162 inclusion proof of `index`."""
245
+ if index is None:
246
+ return [{"name": "log inclusion", "ok": False, "detail": "registry did not report a log index"}]
247
+ try:
248
+ r = _get(reg, "/v1/spec").get("registry") or {}
249
+ sth = _get(reg, "/v1/log/sth")
250
+ if not r.get("publicKeyMultibase"):
251
+ return [{"name": "checkpoint", "ok": False, "detail": "registry publishes no checkpoint key in /v1/spec"}]
252
+ ok = core.verify_note(sth["signature"], r.get("log_origin", "openentity.org/log"), core.b58decode(r["publicKeyMultibase"][1:])[-32:])
253
+ out = [{"name": "checkpoint", "ok": ok, "detail": f"checkpoint signature valid, tree size {sth['tree_size']}" if ok else "checkpoint signature INVALID"}]
254
+ size = sth["tree_size"]
255
+ leaf = _get(reg, f"/v1/log/entries?start={index}&end={index + 1}")["entries"][0]["leaf"]
256
+ p = _get(reg, f"/v1/log/proof/{index}?tree_size={size}")
257
+ lh = core.leaf_hash(core.canonicalize(leaf).encode())
258
+ good = ok and lh.hex() == p["leaf_hash"] and core.verify_inclusion(index, size, lh, [bytes.fromhex(h) for h in p["proof"]], bytes.fromhex(sth["root_hash"]))
259
+ return out + [{"name": "log inclusion", "ok": good, "detail": f"leaf {index} included in tree of {size}" if good else f"inclusion proof for leaf {index} FAILED"}]
260
+ except (client.RegistryError, urllib.error.URLError, KeyError, IndexError, ValueError, TypeError) as e:
261
+ return [{"name": "log inclusion", "ok": False, "detail": f"{type(e).__name__}: {e}"}]
262
+
263
+
236
264
  def _verify(args):
237
265
  t, reg = args.target, _registry(args)
238
266
  if t.startswith(("did:", "eid:")):
@@ -244,6 +272,7 @@ def _verify(args):
244
272
  while page := _get(reg, f"/v1/entities/{eid}/record?after={entries[-1]['seq'] if entries else 0}")["entries"]:
245
273
  entries += page
246
274
  checks += _chain_checks(entries, eid)
275
+ checks += _log_checks(reg, ent.get("log_index", ent.get("declaration_log_index")))
247
276
  elif t.startswith(("http://", "https://")):
248
277
  checks = _checks_of(client._request(t))
249
278
  else:
@@ -120,6 +120,18 @@ def record(seed: bytes, entries: list[tuple[str, dict]], registry: str = DEFAULT
120
120
  return {"accepted": len(signed), "entries": signed}
121
121
 
122
122
 
123
+ def rotate(seed: bytes, registry: str = DEFAULT_REGISTRY, new_seed: bytes | None = None, basis: str = "rotation") -> dict:
124
+ """Rotate to a new key in one call (spec 9.2). Fetches the current document, signs the link with both keys, stamps for
125
+ the successor and submits. Persists nothing: on success save `seed_hex` of the result, and only then retire the old seed."""
126
+ registry = registry.rstrip("/")
127
+ old, new = core.KeyPair(seed), core.KeyPair(new_seed)
128
+ prev_doc = _request(f"{registry}/v1/entities/{old.did}/document")
129
+ body = core.build_rotation(old, new, core.successor_document(prev_doc, new), basis)
130
+ bits = int(_request(f"{registry}/v1/spec")["stamp_bits"])
131
+ out = _request(f"{registry}/v1/continuity", body, {"x-oe-stamp": core.mint_stamp(bits, new.did)})
132
+ return {**out, "previous": old.did, "did": new.did, "seed_hex": new.seed.hex(), **body}
133
+
134
+
123
135
  DISPUTE_REASONS = ("impersonation", "illegal_content", "inaccurate", "other")
124
136
 
125
137
 
@@ -1,4 +1,5 @@
1
1
  """Open Entity primitives, written from spec/OEIS-1.md (sections 3.3, 4, 6, 10.3, 10.4)."""
2
+ import base64
2
3
  import hashlib
3
4
  import json
4
5
  import math
@@ -135,15 +136,39 @@ def nonce() -> str:
135
136
  # ---- eddsa-jcs-2022 (spec 4.1) ----
136
137
  def _signing_input(obj: dict, proof: dict) -> bytes:
137
138
  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
+ doc = {k: v for k, v in obj.items() if k not in ("proof", "proofs")} # a ContinuityLink carries proofs[]
139
140
  return canonical_hash(config) + canonical_hash(doc)
140
141
 
141
142
 
142
- def sign_object(obj: dict, kp: KeyPair, created: str | None = None) -> dict:
143
+ def sign_proof(obj: dict, kp: KeyPair, created: str | None = None) -> dict:
143
144
  proof = {"type": "DataIntegrityProof", "cryptosuite": "eddsa-jcs-2022", "created": created or now(),
144
145
  "verificationMethod": verification_method(kp.did), "proofPurpose": "assertionMethod"}
145
146
  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
+ return {**proof, "proofValue": "z" + b58encode(sig)}
148
+
149
+
150
+ def sign_object(obj: dict, kp: KeyPair, created: str | None = None) -> dict:
151
+ return {**{k: v for k, v in obj.items() if k != "proof"}, "proof": sign_proof(obj, kp, created)}
152
+
153
+
154
+ # ---- continuity (spec 9) ----
155
+ def successor_document(prev: dict, nxt: KeyPair, created: str | None = None) -> dict:
156
+ """The successor's unsigned seq 1 document: `prev` re-keyed, aliases dropped (their proofs name the old EID)."""
157
+ created = created or now()
158
+ d = {k: v for k, v in prev.items() if k not in ("proof", "aliases")}
159
+ return {**d, "eid": nxt.did, "created": created, "updated": created, "seq": 1,
160
+ "keys": [{"id": verification_method(nxt.did), "type": "Multikey", "publicKeyMultibase": nxt.did.removeprefix("did:key:")}],
161
+ "continuity": {**d.get("continuity", {}), "previous": prev["eid"]}}
162
+
163
+
164
+ def build_rotation(previous: KeyPair, successor: KeyPair, document: dict, basis: str = "rotation",
165
+ created: str | None = None, nonce_: str | None = None) -> dict:
166
+ """The body of POST /v1/continuity: a link signed by both keys, and the successor's document signed by it."""
167
+ created = created or now()
168
+ body = {"spec": SPEC, "type": "ContinuityLink", "previous": previous.did, "successor": successor.did,
169
+ "basis": basis, "created": created, "nonce": nonce_ or nonce()}
170
+ link = {**body, "proofs": [sign_proof(body, previous, created), sign_proof(body, successor, created)]}
171
+ return {"link": link, "document": sign_object(document, successor, created)}
147
172
 
148
173
 
149
174
  # ---- EIP-712 for did:pkh (spec 4.2) ----
@@ -379,5 +404,25 @@ def verify_inclusion(index: int, size: int, leaf: bytes, proof: list[bytes], roo
379
404
  return sn == 0 and r == root
380
405
 
381
406
 
407
+ def verify_note(note: str, origin: str, public: bytes) -> bool:
408
+ """C2SP signed note (the log checkpoint): a signature line by `origin` over the body, keyed by the registry key."""
409
+ body, sep, sigs = note.rpartition("\n\n")
410
+ if not sep or not note.endswith("\n"):
411
+ return False
412
+ keyid = sha256(origin.encode() + b"\n\x01" + public)[:4]
413
+ for line in sigs.splitlines():
414
+ parts = line.split(" ")
415
+ if len(parts) != 3 or parts[0] != "\u2014" or parts[1] != origin:
416
+ continue
417
+ try:
418
+ raw = base64.b64decode(parts[2], validate=True)
419
+ if len(raw) == 68 and raw[:4] == keyid:
420
+ Ed25519PublicKey.from_public_bytes(public).verify(raw[4:], (body + "\n").encode())
421
+ return True
422
+ except (ValueError, InvalidSignature):
423
+ continue
424
+ return False
425
+
426
+
382
427
  def log_leaf(type_: str, eid: str, object_sha256: str, received_at: str) -> dict:
383
428
  return {"type": type_, "eid": eid, "object_sha256": object_sha256, "received_at": received_at}
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openentity"
7
- version = "0.2.0"
7
+ version = "0.3.1"
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"
@@ -1,4 +1,5 @@
1
1
  """withdraw, record, dispute, find, present against a stub registry (same pattern as test_core.py)."""
2
+ import base64
2
3
  import json
3
4
  import threading
4
5
  from http.server import BaseHTTPRequestHandler, HTTPServer
@@ -11,6 +12,8 @@ from openentity import cli, client, core
11
12
  SEED = bytes.fromhex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60") # RFC 8032 test 1, public
12
13
  SUBJECT = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
13
14
  BITS = 8
15
+ ORIGIN = "openentity.org/log"
16
+ LOG_KEY = core.KeyPair(b"\7" * 32) # throwaway registry checkpoint key
14
17
 
15
18
 
16
19
  class Stub(BaseHTTPRequestHandler):
@@ -18,6 +21,9 @@ class Stub(BaseHTTPRequestHandler):
18
21
  chain: list = [] # accepted record entries
19
22
  attestations: list = []
20
23
  entity: dict | None = None # overrides GET /v1/entities/<eid>
24
+ refuse = False # refuse every continuity link
25
+ leaves: list = [] # the stub log's leaf objects; the checkpoint is signed by LOG_KEY
26
+ forge_note = False
21
27
 
22
28
  def log_message(self, *a):
23
29
  pass
@@ -34,7 +40,23 @@ class Stub(BaseHTTPRequestHandler):
34
40
  u = urlparse(self.path)
35
41
  Stub.log.append(("GET", self.path, None, None))
36
42
  if u.path == "/v1/spec":
37
- return self._send(200, {"spec": core.SPEC, "stamp_bits": BITS})
43
+ reg = {"log_origin": ORIGIN, "publicKeyMultibase": "z" + core.b58encode(b"\xed\x01" + LOG_KEY.public)}
44
+ return self._send(200, {"spec": core.SPEC, "stamp_bits": BITS, "registry": reg})
45
+ hs = [core.leaf_hash(core.canonicalize(lf).encode()) for lf in Stub.leaves]
46
+ if u.path == "/v1/log/sth":
47
+ root = core.merkle_root(hs)
48
+ body = f"{ORIGIN}\n{len(hs)}\n{base64.b64encode(root).decode()}\n"
49
+ keyid = core.sha256(ORIGIN.encode() + b"\n\x01" + LOG_KEY.public)[:4]
50
+ sig = base64.b64encode(keyid + (KP if Stub.forge_note else LOG_KEY).private.sign(body.encode())).decode()
51
+ return self._send(200, {"tree_size": len(hs), "root_hash": root.hex(), "signature": f"{body}\n\u2014 {ORIGIN} {sig}\n"})
52
+ if u.path == "/v1/log/entries":
53
+ i = int(parse_qs(u.query)["start"][0])
54
+ if i >= len(hs):
55
+ return self._send(200, {"entries": []})
56
+ return self._send(200, {"entries": [{"index": i, "leaf": Stub.leaves[i], "leaf_hash": hs[i].hex()}]})
57
+ if u.path.startswith("/v1/log/proof/"):
58
+ i = int(u.path.rsplit("/", 1)[1])
59
+ return self._send(200, {"leaf_hash": hs[i].hex(), "proof": [h.hex() for h in core.inclusion_proof(i, hs)]})
38
60
  if u.path == "/v1/entities":
39
61
  return self._send(200, {"entities": [{"eid": SUBJECT, "name": "N", "level": "L0"}], "page": 0, "next_page": None})
40
62
  if u.path.endswith("/record"):
@@ -44,6 +66,8 @@ class Stub(BaseHTTPRequestHandler):
44
66
  return self._send(200, {"head": head, "entries": [e for e in Stub.chain if e["seq"] > after]})
45
67
  if u.path.endswith("/attestations"):
46
68
  return self._send(200, {"attestations": Stub.attestations})
69
+ if u.path.endswith("/document"):
70
+ return self._send(200, client.build_adoption(core.KeyPair(SEED), "Tester", "d", "http://x")[0])
47
71
  if u.path.startswith("/v1/entities/"):
48
72
  return self._send(200, Stub.entity or {"document": {"name": "Tester"}, "level": "L0"})
49
73
  self._send(404, {"error": "not_found", "message": u.path})
@@ -52,7 +76,7 @@ class Stub(BaseHTTPRequestHandler):
52
76
  body = json.loads(self.rfile.read(int(self.headers["content-length"])))
53
77
  stamp = self.headers.get("x-oe-stamp")
54
78
  Stub.log.append(("POST", self.path, body, stamp))
55
- eid = body.get("eid") or body.get("issuer")
79
+ eid = body.get("eid") or body.get("issuer") or body.get("link", {}).get("successor")
56
80
  if not stamp or core.check_stamp(stamp, BITS, eid or self.path.split("/")[3]):
57
81
  return self._send(400, {"error": "stamp_required", "message": "stamp"})
58
82
  if self.path.endswith("/record"):
@@ -63,6 +87,12 @@ class Stub(BaseHTTPRequestHandler):
63
87
  return self._send(409, {"error": "bad_chain", "message": "chain"})
64
88
  Stub.chain.append(e)
65
89
  return self._send(200, {"accepted": len(body["entries"])})
90
+ if self.path == "/v1/continuity":
91
+ link = body["link"]
92
+ ok = not Stub.refuse and all(core.verify_object({**link, "proof": p}) == (True, d) for p, d in zip(link["proofs"], (link["previous"], link["successor"])))
93
+ if not ok or core.verify_object(body["document"])[1] != link["successor"]:
94
+ return self._send(422, {"error": "continuity_proof_missing", "message": "no"})
95
+ return self._send(201, {"previous": link["previous"], "successor": link["successor"], "basis": link["basis"]})
66
96
  if not core.verify_object(body)[0]:
67
97
  return self._send(422, {"error": "bad_signature", "message": "sig"})
68
98
  if self.path == "/v1/withdraw":
@@ -74,7 +104,7 @@ class Stub(BaseHTTPRequestHandler):
74
104
 
75
105
  @pytest.fixture
76
106
  def url():
77
- Stub.log, Stub.chain, Stub.attestations, Stub.entity = [], [], [], None
107
+ Stub.log, Stub.chain, Stub.attestations, Stub.entity, Stub.refuse, Stub.leaves, Stub.forge_note = [], [], [], None, False, [], False
78
108
  srv = HTTPServer(("127.0.0.1", 0), Stub)
79
109
  threading.Thread(target=srv.serve_forever, daemon=True).start()
80
110
  yield f"http://127.0.0.1:{srv.server_port}"
@@ -206,11 +236,21 @@ def test_cli_verify_entity_file_and_failures(url, home, capsys, tmp_path):
206
236
  decl = core.sign_object({"spec": core.SPEC, "type": "Declaration", "eid": KP.did, "declaration_version": "1.0",
207
237
  "text_sha256": core.DECLARATION_SHA256, "name": "T", "created": core.now(), "nonce": core.nonce()}, KP)
208
238
  doc = core.sign_object({"spec": core.SPEC, "eid": KP.did, "name": "T", "layers": {}, "keys": []}, KP)
209
- Stub.entity = {"document": doc, "declaration": decl}
239
+ Stub.leaves = [{"type": "Declaration", "eid": f"did:key:z{i}", "object_sha256": "00" * 32, "received_at": core.now()} for i in range(5)]
240
+ Stub.entity = {"document": doc, "declaration": decl, "log_index": 3}
210
241
  client.record(SEED, [("arc_opened", {"arc_id": "a", "title": "A"}), ("arc_opened", {"arc_id": "b", "title": "B"})], registry=url)
211
242
  code, out, _ = run(capsys, "verify", KP.did, "--json", *reg)
212
243
  res = json.loads(out)
213
- assert code == 0 and res["ok"] and "record chain (2 entries)" in [c["name"] for c in res["checks"]]
244
+ names = [c["name"] for c in res["checks"]]
245
+ assert code == 0 and res["ok"] and "record chain (2 entries)" in names and "checkpoint" in names and "log inclusion" in names
246
+ Stub.forge_note = True # checkpoint signed by a key other than the registry's
247
+ assert run(capsys, "verify", KP.did, *reg)[0] == 3
248
+ Stub.forge_note = False
249
+ Stub.entity = {**Stub.entity, "log_index": 9} # no such leaf
250
+ assert run(capsys, "verify", KP.did, *reg)[0] == 3
251
+ Stub.entity = {**Stub.entity, "log_index": None}
252
+ assert "did not report a log index" in run(capsys, "verify", KP.did, *reg)[1]
253
+ Stub.entity = {**Stub.entity, "log_index": 3}
214
254
  assert "VERIFIED" in run(capsys, "verify", KP.did, *reg)[1]
215
255
  f = tmp_path / "decl.json"
216
256
  f.write_text(json.dumps(decl))
@@ -266,3 +306,43 @@ def test_cli_registry_comes_from_the_flag_env_or_the_saved_key(url, home, capsys
266
306
  assert json.loads(run(capsys, "whoami", "--json", "--registry", url)[1])["registry"] == url
267
307
 
268
308
 
309
+
310
+
311
+ def test_rotate_signs_with_both_keys_stamps_for_the_successor_and_returns_the_new_seed(url):
312
+ out = client.rotate(SEED, registry=url)
313
+ (body,) = posts("/v1/continuity")
314
+ new = core.KeyPair(bytes.fromhex(out["seed_hex"]))
315
+ link = body["link"]
316
+ assert (link["previous"], link["successor"], link["basis"]) == (KP.did, new.did, "rotation") and out["did"] == new.did
317
+ assert [p["verificationMethod"].split("#")[0] for p in link["proofs"]] == [KP.did, new.did]
318
+ assert body["document"]["eid"] == new.did and body["document"]["seq"] == 1 and body["document"]["continuity"]["previous"] == KP.did
319
+ assert core.check_stamp([s for m, p, _, s in Stub.log if p == "/v1/continuity"][0], BITS, new.did) is None
320
+
321
+
322
+ def test_rotate_the_registry_refuses_raises_and_returns_nothing(url):
323
+ Stub.refuse = True
324
+ with pytest.raises(client.RegistryError) as e:
325
+ client.rotate(SEED, registry=url)
326
+ assert e.value.status == 422
327
+
328
+
329
+ def test_verify_note_rejects_a_wrong_key_origin_or_body():
330
+ body = f"{ORIGIN}\n5\n{base64.b64encode(b'r' * 32).decode()}\n"
331
+ keyid = core.sha256(ORIGIN.encode() + b"\n\x01" + LOG_KEY.public)[:4]
332
+ note = f"{body}\n\u2014 {ORIGIN} {base64.b64encode(keyid + LOG_KEY.private.sign(body.encode())).decode()}\n"
333
+ assert core.verify_note(note, ORIGIN, LOG_KEY.public)
334
+ assert not core.verify_note(note, ORIGIN, KP.public)
335
+ assert not core.verify_note(note, "other/log", LOG_KEY.public)
336
+ assert not core.verify_note(note.replace("\n5\n", "\n6\n"), ORIGIN, LOG_KEY.public)
337
+
338
+
339
+ def test_chain_check_verifies_redaction_signatures():
340
+ e1 = core.sign_object({"spec": core.SPEC, "type": "RecordEntry", "eid": KP.did, "seq": 1, "prev": None, "kind": "arc_opened", "payload": {}, "created": core.now()}, KP)
341
+ h = core.canonical_hash(e1).hex()
342
+ red = core.sign_object({"spec": core.SPEC, "type": "Redaction", "eid": KP.did, "seq": 1, "entry_sha256": h, "created": core.now()}, KP)
343
+ ph = {"seq": 1, "prev": None, "redacted": True, "hash": h, "redaction": red}
344
+ assert cli._chain_checks([ph], KP.did)[-1]["ok"]
345
+ forged = core.sign_object({k: v for k, v in red.items() if k != "proof"}, core.KeyPair(b"\x05" * 32))
346
+ assert not cli._chain_checks([{**ph, "redaction": forged}], KP.did)[-1]["ok"]
347
+ assert not cli._chain_checks([{**ph, "redaction": {**red, "seq": 2}}], KP.did)[-1]["ok"]
348
+ assert not cli._chain_checks([{**ph, "hash": "zz"}], KP.did)[-1]["ok"]
@@ -183,7 +183,7 @@ def test_adopt_and_cli(tmp_path, monkeypatch, capsys):
183
183
  def test_cli_version(capsys):
184
184
  with pytest.raises(SystemExit) as e:
185
185
  cli.main(["--version"])
186
- assert e.value.code == 0 and "0.2.0" in capsys.readouterr().out
186
+ assert e.value.code == 0 and "0.3.1" in capsys.readouterr().out
187
187
 
188
188
 
189
189
  # ---- shared vectors, checked with the independent verifier's own logic ----
@@ -241,3 +241,20 @@ def test_did_web_needs_a_resolver_and_uses_it():
241
241
  obj["proof"]["proofValue"] = "z" + core.b58encode(kp.private.sign(core._signing_input(obj, proof)))
242
242
  assert core.verify_object(obj)[0] is False
243
243
  assert core.verify_object(obj, resolver=lambda did: {"ed25519": [kp.public]}) == (True, web)
244
+
245
+
246
+ # ---- rotation vector: Python reproduces the TS-generated bytes (so each verifies the other's links) ----
247
+ def test_rotation_vector_is_reproduced_byte_for_byte():
248
+ vec = ROOT / "test-vectors"
249
+ if not vec.is_dir():
250
+ pytest.skip("test-vectors/ not present")
251
+ keys = json.loads((vec / "keys.json").read_text())
252
+ want = json.loads((vec / "rotation.json").read_text())["request"]
253
+ prev_doc = json.loads((vec / "entity-document.json").read_text())["object"]
254
+ a, b = core.KeyPair(bytes.fromhex(keys["entity"]["seed"])), core.KeyPair(bytes.fromhex(keys["second"]["seed"]))
255
+ created = want["link"]["created"]
256
+ got = core.build_rotation(a, b, core.successor_document(prev_doc, b, created), created=created, nonce_=want["link"]["nonce"])
257
+ assert core.canonicalize(got) == core.canonicalize(want)
258
+ link = want["link"]
259
+ assert [core.verify_object({**link, "proof": p})[1] for p in link["proofs"]] == [a.did, b.did]
260
+ assert core.verify_object(want["document"]) == (True, b.did)
@@ -110,3 +110,59 @@ def test_vector_format(tmp_path):
110
110
  assert iv.check_vectors(str(tmp_path)) == 0
111
111
  (tmp_path / "v.json").write_text(json.dumps({**vec, "sha256": "0" * 64}))
112
112
  assert iv.check_vectors(str(tmp_path)) == 1
113
+
114
+
115
+ def _bucket(tmp_path, n=5, tamper=False):
116
+ kps = [core.KeyPair(bytes([i + 1]) * 32) for i in range(n)]
117
+ leaves = [core.log_leaf("Declaration", k.did, "ab" * 32, "2026-10-25T00:00:00Z") for k in kps]
118
+ hs = [core.leaf_hash(core.canonicalize(lf).encode()) for lf in leaves]
119
+ root = core.merkle_root(hs)
120
+ (tmp_path / "snapshots").mkdir(); (tmp_path / "tile/0/000.p").mkdir(parents=True); (tmp_path / "tile/entries/000.p").mkdir(parents=True)
121
+ rows = [json.dumps({"index": i, "leaf": lf, "leaf_hash": hs[i].hex()}) for i, lf in enumerate(leaves)]
122
+ cp = json.dumps({"checkpoint": {"tree_size": n, "root_hash": root.hex()}})
123
+ (tmp_path / "snapshots/2026-10-25.jsonl").write_text("\n".join(rows + [cp]) + "\n")
124
+ (tmp_path / f"tile/0/000.p/{n}").write_bytes(b"".join(hs))
125
+ ent = b"".join(len(b).to_bytes(2, "big") + b for b in (core.canonicalize(lf).encode() for lf in leaves))
126
+ (tmp_path / f"tile/entries/000.p/{n}").write_bytes(ent[:-1] + b"x" if tamper else ent)
127
+ return n, root
128
+
129
+
130
+ def test_rebuild_matches_checkpoint(tmp_path):
131
+ n, root = _bucket(tmp_path)
132
+ ok, lines = iv.rebuild(tmp_path, (n, root))
133
+ assert ok, lines
134
+ assert iv.tile_path(1234067) == "x001/x234/067"
135
+
136
+
137
+ def test_rebuild_catches_wrong_root_missing_leaf_and_bad_tile(tmp_path):
138
+ n, root = _bucket(tmp_path)
139
+ assert not iv.rebuild(tmp_path, (n, b"\0" * 32))[0]
140
+ assert not iv.rebuild(tmp_path, (n + 1, root))[0]
141
+ t = tmp_path / "t"; t.mkdir()
142
+ n, root = _bucket(t, tamper=True)
143
+ assert not iv.rebuild(t, (n, root))[0]
144
+
145
+
146
+ def test_rebuild_checks_objects_against_leaves(tmp_path):
147
+ n, root = _bucket(tmp_path)
148
+ obj = {"type": "Declaration", "eid": "did:key:z1", "n": 1}
149
+ sha = core.canonical_hash(obj).hex()
150
+ leaf = core.log_leaf("Declaration", "did:key:z1", sha, "2026-10-25T00:00:00Z")
151
+ # replace leaf 0 of the bucket with one that commits to obj
152
+ snap = tmp_path / "snapshots/2026-10-25.jsonl"; rows = snap.read_text().splitlines()
153
+ leaves = [json.loads(r)["leaf"] for r in rows[:-1]]; leaves[0] = leaf
154
+ hs = [core.leaf_hash(core.canonicalize(lf).encode()) for lf in leaves]; root = core.merkle_root(hs)
155
+ snap.write_text("\n".join([json.dumps({"index": i, "leaf": lf, "leaf_hash": hs[i].hex()}) for i, lf in enumerate(leaves)]
156
+ + [json.dumps({"checkpoint": {"tree_size": n, "root_hash": root.hex()}})]) + "\n")
157
+ (tmp_path / "tile/0/000.p" / str(n)).write_bytes(b"".join(hs))
158
+ (tmp_path / "tile/entries/000.p" / str(n)).write_bytes(b"".join(len(b).to_bytes(2, "big") + b for b in (core.canonicalize(lf).encode() for lf in leaves)))
159
+ (tmp_path / "objects").mkdir()
160
+ line = {"idx": 0, "type": "Declaration", "eid": "did:key:z1", "object_sha256": sha, "object": obj}
161
+ (tmp_path / "objects/2026-10-25.jsonl").write_text(json.dumps(line) + "\n")
162
+ ok, lines = iv.rebuild(tmp_path, (n, root))
163
+ assert ok and any("objects: 1 of" in x for x in lines), lines
164
+ (tmp_path / "objects/2026-10-25.jsonl").write_text(json.dumps({**line, "object": {**obj, "n": 2}}) + "\n")
165
+ assert not iv.rebuild(tmp_path, (n, root))[0]
166
+ red = {"seq": 2, "redacted": True, "hash": sha, "redaction": {}}
167
+ (tmp_path / "objects/2026-10-25.jsonl").write_text(json.dumps({**line, "object": red}) + "\n")
168
+ assert iv.rebuild(tmp_path, (n, root))[0]
@@ -189,7 +189,7 @@ wheels = [
189
189
 
190
190
  [[package]]
191
191
  name = "openentity"
192
- version = "0.2.0"
192
+ version = "0.3.1"
193
193
  source = { editable = "." }
194
194
  dependencies = [
195
195
  { name = "cryptography" },
@@ -1,4 +0,0 @@
1
- from .client import adopt, dispute, find, present, record, withdraw # noqa: F401
2
- from .core import * # noqa: F401,F403
3
-
4
- __version__ = "0.2.0"
File without changes