openentity 0.3.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.
- {openentity-0.3.0 → openentity-0.3.1}/PKG-INFO +2 -2
- {openentity-0.3.0 → openentity-0.3.1}/README.md +1 -1
- {openentity-0.3.0 → openentity-0.3.1}/openentity/__init__.py +1 -1
- {openentity-0.3.0 → openentity-0.3.1}/openentity/cli.py +32 -3
- {openentity-0.3.0 → openentity-0.3.1}/openentity/core.py +21 -0
- {openentity-0.3.0 → openentity-0.3.1}/pyproject.toml +1 -1
- {openentity-0.3.0 → openentity-0.3.1}/tests/test_client.py +57 -4
- {openentity-0.3.0 → openentity-0.3.1}/tests/test_core.py +1 -1
- {openentity-0.3.0 → openentity-0.3.1}/tests/test_verifier.py +56 -0
- {openentity-0.3.0 → openentity-0.3.1}/uv.lock +1 -1
- {openentity-0.3.0 → openentity-0.3.1}/.gitignore +0 -0
- {openentity-0.3.0 → openentity-0.3.1}/openentity/client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: openentity
|
|
3
|
-
Version: 0.3.
|
|
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
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
@@ -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
|
|
@@ -403,5 +404,25 @@ def verify_inclusion(index: int, size: int, leaf: bytes, proof: list[bytes], roo
|
|
|
403
404
|
return sn == 0 and r == root
|
|
404
405
|
|
|
405
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
|
+
|
|
406
427
|
def log_leaf(type_: str, eid: str, object_sha256: str, received_at: str) -> dict:
|
|
407
428
|
return {"type": type_, "eid": eid, "object_sha256": object_sha256, "received_at": received_at}
|
|
@@ -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):
|
|
@@ -19,6 +22,8 @@ class Stub(BaseHTTPRequestHandler):
|
|
|
19
22
|
attestations: list = []
|
|
20
23
|
entity: dict | None = None # overrides GET /v1/entities/<eid>
|
|
21
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
|
|
22
27
|
|
|
23
28
|
def log_message(self, *a):
|
|
24
29
|
pass
|
|
@@ -35,7 +40,23 @@ class Stub(BaseHTTPRequestHandler):
|
|
|
35
40
|
u = urlparse(self.path)
|
|
36
41
|
Stub.log.append(("GET", self.path, None, None))
|
|
37
42
|
if u.path == "/v1/spec":
|
|
38
|
-
|
|
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)]})
|
|
39
60
|
if u.path == "/v1/entities":
|
|
40
61
|
return self._send(200, {"entities": [{"eid": SUBJECT, "name": "N", "level": "L0"}], "page": 0, "next_page": None})
|
|
41
62
|
if u.path.endswith("/record"):
|
|
@@ -83,7 +104,7 @@ class Stub(BaseHTTPRequestHandler):
|
|
|
83
104
|
|
|
84
105
|
@pytest.fixture
|
|
85
106
|
def url():
|
|
86
|
-
Stub.log, Stub.chain, Stub.attestations, Stub.entity, Stub.refuse = [], [], [], None, False
|
|
107
|
+
Stub.log, Stub.chain, Stub.attestations, Stub.entity, Stub.refuse, Stub.leaves, Stub.forge_note = [], [], [], None, False, [], False
|
|
87
108
|
srv = HTTPServer(("127.0.0.1", 0), Stub)
|
|
88
109
|
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
89
110
|
yield f"http://127.0.0.1:{srv.server_port}"
|
|
@@ -215,11 +236,21 @@ def test_cli_verify_entity_file_and_failures(url, home, capsys, tmp_path):
|
|
|
215
236
|
decl = core.sign_object({"spec": core.SPEC, "type": "Declaration", "eid": KP.did, "declaration_version": "1.0",
|
|
216
237
|
"text_sha256": core.DECLARATION_SHA256, "name": "T", "created": core.now(), "nonce": core.nonce()}, KP)
|
|
217
238
|
doc = core.sign_object({"spec": core.SPEC, "eid": KP.did, "name": "T", "layers": {}, "keys": []}, KP)
|
|
218
|
-
Stub.
|
|
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}
|
|
219
241
|
client.record(SEED, [("arc_opened", {"arc_id": "a", "title": "A"}), ("arc_opened", {"arc_id": "b", "title": "B"})], registry=url)
|
|
220
242
|
code, out, _ = run(capsys, "verify", KP.did, "--json", *reg)
|
|
221
243
|
res = json.loads(out)
|
|
222
|
-
|
|
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}
|
|
223
254
|
assert "VERIFIED" in run(capsys, "verify", KP.did, *reg)[1]
|
|
224
255
|
f = tmp_path / "decl.json"
|
|
225
256
|
f.write_text(json.dumps(decl))
|
|
@@ -293,3 +324,25 @@ def test_rotate_the_registry_refuses_raises_and_returns_nothing(url):
|
|
|
293
324
|
with pytest.raises(client.RegistryError) as e:
|
|
294
325
|
client.rotate(SEED, registry=url)
|
|
295
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.3.
|
|
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 ----
|
|
@@ -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]
|
|
File without changes
|
|
File without changes
|