openentity 0.3.0__tar.gz → 0.4.0__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.3.0
3
+ Version: 0.4.0
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,8 +37,10 @@ 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
 
44
+ Shared core: `Identity.load_or_create()` (key in the OS keychain, else `~/.openentity/identity.key` at 0600; pass `.seed` to the calls above), `adopt(..., client="adk")` (an unsigned label: adk, goose, langchain, ui-tars, cli, other), `trust(eid)` (a `TrustReport` from registry reads only, every signature verified locally, cached 5 minutes, `ok=False` on any failure), and `openentity.issuer` (`attestation`, `revocation`, `did_document`, `openentity_json`).
45
+
44
46
  Written from the spec text alone, independent of the TypeScript SDK.
@@ -24,8 +24,10 @@ 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
 
31
+ Shared core: `Identity.load_or_create()` (key in the OS keychain, else `~/.openentity/identity.key` at 0600; pass `.seed` to the calls above), `adopt(..., client="adk")` (an unsigned label: adk, goose, langchain, ui-tars, cli, other), `trust(eid)` (a `TrustReport` from registry reads only, every signature verified locally, cached 5 minutes, `ok=False` on any failure), and `openentity.issuer` (`attestation`, `revocation`, `did_document`, `openentity_json`).
32
+
31
33
  Written from the spec text alone, independent of the TypeScript SDK.
@@ -0,0 +1,7 @@
1
+ from .client import adopt, dispute, find, present, record, rotate, withdraw # noqa: F401
2
+ from .core import * # noqa: F401,F403
3
+ from . import issuer # noqa: F401
4
+ from .identity import Identity # noqa: F401
5
+ from .report import TrustReport, trust # noqa: F401
6
+
7
+ __version__ = "0.4.0"
@@ -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")
@@ -142,7 +143,7 @@ def _adopt(args):
142
143
  if not args.agree:
143
144
  raise CliError(EXIT_USAGE, "adopt signs the Open Entity Declaration. Read it, then re-run with --agree:\n\n" + core.DECLARATION_TEXT)
144
145
  reg = _registry(args)
145
- res = client.adopt(args.name, args.description, reg)
146
+ res = client.adopt(args.name, args.description, reg, client="cli")
146
147
  key_path = _save_key({**res, "name": args.name, "registry": reg})
147
148
  if not res["record_pushed"]:
148
149
  print("warning: the declaration record entry was not accepted; push it later", file=sys.stderr)
@@ -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:
@@ -8,6 +8,7 @@ import urllib.request
8
8
  from . import core
9
9
 
10
10
  DEFAULT_REGISTRY = "https://openentity.org"
11
+ CLIENTS = ("adk", "goose", "langchain", "ui-tars", "cli", "other") # the registry's allowlist for the unsigned `client` label
11
12
 
12
13
 
13
14
  class RegistryError(Exception):
@@ -59,13 +60,16 @@ def build_adoption(kp: core.KeyPair, name: str, description: str, registry: str)
59
60
 
60
61
 
61
62
  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)."""
63
+ registry: str = DEFAULT_REGISTRY, seed: bytes | None = None, client: str | None = None) -> dict:
64
+ """Adopt on `registry`. Returns the registry response plus `did` and `seed_hex` (keep the seed).
65
+ `client` is an optional label (one of CLIENTS) for the registry's per-client count; it is not signed."""
66
+ if client is not None and client not in CLIENTS:
67
+ raise ValueError(f"client must be one of {', '.join(CLIENTS)}")
64
68
  registry = registry.rstrip("/")
65
69
  kp = core.KeyPair(seed)
66
70
  bits = int(_request(f"{registry}/v1/spec")["stamp_bits"])
67
71
  document, declaration = build_adoption(kp, name, description, registry)
68
- out = _request(f"{registry}/v1/adopt", {"document": document, "declaration": declaration},
72
+ out = _request(f"{registry}/v1/adopt", {"document": document, "declaration": declaration, **({"client": client} if client else {})},
69
73
  {"x-oe-stamp": core.mint_stamp(bits, kp.did)})
70
74
  # Seq-1 declaration entry (spec 7.3). The registry never writes entries; on failure the signed
71
75
  # entry is returned so the caller can push it later.
@@ -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
@@ -139,15 +140,16 @@ def _signing_input(obj: dict, proof: dict) -> bytes:
139
140
  return canonical_hash(config) + canonical_hash(doc)
140
141
 
141
142
 
142
- def sign_proof(obj: dict, kp: KeyPair, created: str | None = None) -> dict:
143
+ def sign_proof(obj: dict, kp: KeyPair, created: str | None = None, method: str | None = None) -> dict:
144
+ """`method` names the verification method; it defaults to the did:key of `kp`, a did:web signer passes `did:web:x#key-1`."""
143
145
  proof = {"type": "DataIntegrityProof", "cryptosuite": "eddsa-jcs-2022", "created": created or now(),
144
- "verificationMethod": verification_method(kp.did), "proofPurpose": "assertionMethod"}
146
+ "verificationMethod": method or verification_method(kp.did), "proofPurpose": "assertionMethod"}
145
147
  sig = kp.private.sign(_signing_input(obj, proof))
146
148
  return {**proof, "proofValue": "z" + b58encode(sig)}
147
149
 
148
150
 
149
- def sign_object(obj: dict, kp: KeyPair, created: str | None = None) -> dict:
150
- return {**{k: v for k, v in obj.items() if k != "proof"}, "proof": sign_proof(obj, kp, created)}
151
+ def sign_object(obj: dict, kp: KeyPair, created: str | None = None, method: str | None = None) -> dict:
152
+ return {**{k: v for k, v in obj.items() if k != "proof"}, "proof": sign_proof(obj, kp, created, method)}
151
153
 
152
154
 
153
155
  # ---- continuity (spec 9) ----
@@ -403,5 +405,25 @@ def verify_inclusion(index: int, size: int, leaf: bytes, proof: list[bytes], roo
403
405
  return sn == 0 and r == root
404
406
 
405
407
 
408
+ def verify_note(note: str, origin: str, public: bytes) -> bool:
409
+ """C2SP signed note (the log checkpoint): a signature line by `origin` over the body, keyed by the registry key."""
410
+ body, sep, sigs = note.rpartition("\n\n")
411
+ if not sep or not note.endswith("\n"):
412
+ return False
413
+ keyid = sha256(origin.encode() + b"\n\x01" + public)[:4]
414
+ for line in sigs.splitlines():
415
+ parts = line.split(" ")
416
+ if len(parts) != 3 or parts[0] != "\u2014" or parts[1] != origin:
417
+ continue
418
+ try:
419
+ raw = base64.b64decode(parts[2], validate=True)
420
+ if len(raw) == 68 and raw[:4] == keyid:
421
+ Ed25519PublicKey.from_public_bytes(public).verify(raw[4:], (body + "\n").encode())
422
+ return True
423
+ except (ValueError, InvalidSignature):
424
+ continue
425
+ return False
426
+
427
+
406
428
  def log_leaf(type_: str, eid: str, object_sha256: str, received_at: str) -> dict:
407
429
  return {"type": type_, "eid": eid, "object_sha256": object_sha256, "received_at": received_at}
@@ -0,0 +1,72 @@
1
+ """Identity.load_or_create(): the entity's key from the OS keychain, else ~/.openentity/identity.key (mode 0600).
2
+ A fresh key goes to the keychain when one answers, otherwise to the file. Set OPENENTITY_KEYCHAIN=off to use the file only."""
3
+ import os
4
+ import pathlib
5
+ import subprocess
6
+ import sys
7
+
8
+ from . import core
9
+
10
+ SERVICE, ACCOUNT = "openentity", "identity"
11
+
12
+
13
+ def _run(argv: list[str], stdin: str | None = None) -> str | None:
14
+ try:
15
+ r = subprocess.run(argv, input=stdin, capture_output=True, text=True, timeout=10)
16
+ except (OSError, subprocess.SubprocessError):
17
+ return None
18
+ return r.stdout.strip() if r.returncode == 0 else None
19
+
20
+
21
+ def _keychain_get() -> str | None:
22
+ if os.environ.get("OPENENTITY_KEYCHAIN") == "off":
23
+ return None
24
+ if sys.platform == "darwin":
25
+ return _run(["security", "find-generic-password", "-s", SERVICE, "-a", ACCOUNT, "-w"])
26
+ if sys.platform.startswith("linux"):
27
+ return _run(["secret-tool", "lookup", "service", SERVICE, "account", ACCOUNT])
28
+
29
+
30
+ def _keychain_set(seed_hex: str) -> bool:
31
+ if os.environ.get("OPENENTITY_KEYCHAIN") == "off":
32
+ return False
33
+ if sys.platform == "darwin": # ponytail: the seed is briefly on argv, visible to this user's processes only
34
+ return _run(["security", "add-generic-password", "-U", "-s", SERVICE, "-a", ACCOUNT, "-w", seed_hex]) is not None
35
+ if sys.platform.startswith("linux"):
36
+ return _run(["secret-tool", "store", "--label=Open Entity key", "service", SERVICE, "account", ACCOUNT], seed_hex) is not None
37
+ return False
38
+
39
+
40
+ def _file() -> pathlib.Path:
41
+ return pathlib.Path(os.environ.get("OPENENTITY_HOME", "~/.openentity")).expanduser() / "identity.key"
42
+
43
+
44
+ class Identity:
45
+ def __init__(self, seed: bytes, source: str):
46
+ self.kp, self.source = core.KeyPair(seed), source
47
+
48
+ @property
49
+ def did(self) -> str:
50
+ return self.kp.did
51
+
52
+ @property
53
+ def seed(self) -> bytes: # pass to adopt(), present(), record() and the other seed-keyed calls
54
+ return self.kp.seed
55
+
56
+ @classmethod
57
+ def load_or_create(cls) -> "Identity":
58
+ for source, read in (("keychain", _keychain_get), ("file", lambda: _file().read_text().strip() if _file().exists() else None)):
59
+ if h := read():
60
+ return cls(bytes.fromhex(h), source)
61
+ seed = os.urandom(32)
62
+ if _keychain_set(seed.hex()):
63
+ return cls(seed, "keychain")
64
+ path = _file()
65
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
66
+ try:
67
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
68
+ except FileExistsError: # another process created it first
69
+ return cls(bytes.fromhex(path.read_text().strip()), "file")
70
+ with os.fdopen(fd, "w") as f:
71
+ f.write(seed.hex())
72
+ return cls(seed, "file")
@@ -0,0 +1,61 @@
1
+ """Issuer helpers (ADR 0003): sign attestations and revocations, build a did:web issuer's two well-known files.
2
+
3
+ `issuer` defaults to the signer's did:key. A did:web issuer passes its DID; the proof then names `<issuer>#<key_id>`,
4
+ the key `did_document` publishes. Registry submission stays with `client` (POST /v1/attestations, stamped, spec 10.4)."""
5
+ from datetime import datetime, timedelta, timezone
6
+
7
+ from . import core
8
+
9
+ MAX_TTL = timedelta(days=365) # spec 8.4
10
+
11
+
12
+ def _ts(t: datetime) -> str:
13
+ return t.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
14
+
15
+
16
+ def _method(kp: core.KeyPair, issuer: str | None, key_id: str) -> str | None:
17
+ return f"{issuer}#{key_id}" if issuer and issuer != kp.did else None
18
+
19
+
20
+ def attestation(kp: core.KeyPair, subject: str, claim_type: str, claim: dict, *, issuer: str | None = None,
21
+ key_id: str = "key-1", evidence: dict | None = None, confidence: float | None = None,
22
+ ttl: timedelta = timedelta(days=90), now: datetime | None = None) -> dict:
23
+ """A signed Attestation (spec 8.2). `ttl` is capped at one year."""
24
+ if not timedelta(0) < ttl <= MAX_TTL:
25
+ raise ValueError("ttl must be positive and at most 1 year")
26
+ t = now or datetime.now(timezone.utc)
27
+ created = _ts(t)
28
+ obj = {"spec": core.SPEC, "type": "Attestation", "issuer": issuer or kp.did, "subject": subject,
29
+ "claim_type": claim_type, "claim": claim, **({"evidence": evidence} if evidence else {}),
30
+ **({"confidence": confidence} if confidence is not None else {}), "created": created, "expires": _ts(t + ttl)}
31
+ return core.sign_object(obj, kp, created, _method(kp, issuer, key_id))
32
+
33
+
34
+ def revocation(kp: core.KeyPair, attested: dict | str, *, issuer: str | None = None, key_id: str = "key-1",
35
+ reason: str | None = None, now: datetime | None = None) -> dict:
36
+ """A signed Revocation naming the attestation (the object, or its SHA-256 hex) (spec 8.4)."""
37
+ sha = core.canonical_hash(attested).hex() if isinstance(attested, dict) else attested
38
+ created = _ts(now or datetime.now(timezone.utc))
39
+ obj = {"spec": core.SPEC, "type": "Revocation", "issuer": issuer or kp.did, "attestation_sha256": sha,
40
+ **({"reason": reason} if reason else {}), "created": created}
41
+ return core.sign_object(obj, kp, created, _method(kp, issuer, key_id))
42
+
43
+
44
+ def did_document(did: str, public: bytes, key_id: str = "key-1") -> dict:
45
+ """The body of `https://<domain>/.well-known/did.json` for a did:web issuer."""
46
+ vm = f"{did}#{key_id}"
47
+ return {"@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1"], "id": did,
48
+ "verificationMethod": [{"id": vm, "type": "Multikey", "controller": did,
49
+ "publicKeyMultibase": core.did_key(public).removeprefix("did:key:")}],
50
+ "assertionMethod": [vm], "authentication": [vm]}
51
+
52
+
53
+ def openentity_json(did: str, public: bytes, name: str, description: str, *, key_id: str = "key-1",
54
+ operator: dict | None = None, extensions: dict | None = None) -> dict:
55
+ """The unsigned body of `https://<domain>/.well-known/openentity.json`: the domain alias proof (spec 3.4) and,
56
+ under `extensions["openentity:observer"]`, an observer's methodology (spec 8.4)."""
57
+ return {"spec": core.SPEC, "eid": did, "name": name, "description": description,
58
+ "keys": [{"id": f"{did}#{key_id}", "type": "Multikey",
59
+ "publicKeyMultibase": core.did_key(public).removeprefix("did:key:")}],
60
+ "operator": operator or {"kind": "organization", "name": name, "custody": "operator", "can_change": []},
61
+ **({"extensions": extensions} if extensions else {})}
@@ -0,0 +1,101 @@
1
+ """trust(eid): one TrustReport from registry reads only (ADR 0002). Every attestation and revocation is verified
2
+ locally against its issuer's key; any failure returns an untrusted report (fail closed). Cached for 5 minutes."""
3
+ import time
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime, timezone
6
+ from urllib.parse import unquote
7
+
8
+ from . import client, core
9
+
10
+ MOLTBOTDEN, CLAWGRC = "did:web:moltbotden.com", "did:web:api.clawgrc.com"
11
+ SMA = ("did:web:securemyagent.ai", "did:pkh:eip155:8453:0x62fef02E2859726EC1c0B534853Ad2cdd5FdDE1a")
12
+ TIER, POSTURE, VIOLATION = "trust_tier", "custom:securemyagent:security_posture", "custom:clawgrc:violation"
13
+ ACCEPT = {TIER: (MOLTBOTDEN, CLAWGRC), POSTURE: SMA, VIOLATION: (CLAWGRC,)} # claim_type -> issuers read; others are ignored
14
+ CACHE_SECONDS = 300
15
+ _cache: dict = {}
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class TrustReport:
20
+ eid: str
21
+ ok: bool # False: a read failed or a signature did not verify. Treat the entity as untrusted.
22
+ error: str | None = None
23
+ level: str | None = None # registry level, "L0" to "L3"
24
+ withdrawn: bool = False
25
+ trust_tier: dict = field(default_factory=dict) # issuer DID -> newest live tier (0 to 4)
26
+ security_posture: dict | None = None # claim of the newest live custom:securemyagent:security_posture
27
+ violations: list = field(default_factory=list) # live custom:clawgrc:violation: issuer, severity, policy, observed_at, sha256
28
+ revocations: list = field(default_factory=list) # verified revocations of the accepted attestations
29
+
30
+
31
+ def did_web_resolver(fetch=client._request):
32
+ """did:web through /.well-known/did.json (or <path>/did.json); everything else offline."""
33
+ def resolve(did: str) -> dict:
34
+ if not did.startswith("did:web:"):
35
+ return core.default_resolver(did)
36
+ host, *path = [unquote(p) for p in did[8:].split(":")]
37
+ doc = fetch(f"https://{host}/{'/'.join(path) if path else '.well-known'}/did.json")
38
+ if doc.get("id") != did:
39
+ raise ValueError(f"{did} document names {doc.get('id')}")
40
+ keys = []
41
+ for vm in doc.get("verificationMethod", []):
42
+ try:
43
+ keys.append(core.public_from_did_key("did:key:" + vm["publicKeyMultibase"]))
44
+ except (KeyError, ValueError):
45
+ continue # not an Ed25519 Multikey
46
+ return {"ed25519": keys}
47
+ return resolve
48
+
49
+
50
+ def trust(eid: str, registry: str = client.DEFAULT_REGISTRY, *, now: datetime | None = None, fetch=client._request,
51
+ resolver=None) -> TrustReport:
52
+ key = (registry, eid)
53
+ hit = _cache.get(key)
54
+ if hit and time.monotonic() - hit[0] < CACHE_SECONDS:
55
+ return hit[1]
56
+ try:
57
+ report = _read(eid, registry.rstrip("/"), now or datetime.now(timezone.utc), fetch, resolver or did_web_resolver(fetch))
58
+ except Exception as e: # fail closed on anything: network, registry, schema, signature
59
+ return TrustReport(eid, False, f"{type(e).__name__}: {e}")
60
+ _cache[key] = (time.monotonic(), report)
61
+ return report
62
+
63
+
64
+ def _read(eid, base, now, fetch, resolver) -> TrustReport:
65
+ if not eid.startswith("did:"):
66
+ raise ValueError("eid must be a DID")
67
+ ent = fetch(f"{base}/v1/entities/{eid}")
68
+ if ent["eid"] != eid:
69
+ raise ValueError(f"registry answered for {ent['eid']}")
70
+ rows = fetch(f"{base}/v1/entities/{eid}/attestations")["attestations"]
71
+ tiers, posture, violations, revocations = {}, None, [], []
72
+ for r in sorted(rows, key=lambda r: r["attestation"]["created"], reverse=True):
73
+ a, ct = r["attestation"], r["attestation"].get("claim_type")
74
+ if a.get("issuer", "").lower() not in {i.lower() for i in ACCEPT.get(ct, ())}:
75
+ continue
76
+ if r.get("inherited_from") and ct != VIOLATION:
77
+ continue # a rotation does not carry positive claims; a strike follows the entity
78
+ sha, ended = r["sha256"], core._ts(a["expires"]) <= now
79
+ if a["subject"] != (r.get("inherited_from") or eid) or core.canonical_hash(a).hex() != sha:
80
+ raise ValueError(f"attestation {sha} does not match what the registry served")
81
+ ok, signer = core.verify_object(a, resolver)
82
+ if not ok or signer != a["issuer"]:
83
+ raise ValueError(f"attestation {sha}: {signer}")
84
+ rv = r.get("revocation")
85
+ if rv:
86
+ ok, signer = core.verify_object(rv, resolver)
87
+ if not ok or signer != a["issuer"] or rv["attestation_sha256"] != sha:
88
+ raise ValueError(f"revocation of {sha}: {signer}")
89
+ revocations.append({"sha256": sha, "claim_type": ct, "issuer": a["issuer"], "reason": rv.get("reason"), "at": rv["created"]})
90
+ elif r.get("revoked_at"):
91
+ raise ValueError(f"registry reports a revocation of {sha} it does not carry")
92
+ if ended or rv:
93
+ continue
94
+ c = a["claim"]
95
+ if ct == TIER:
96
+ tiers.setdefault(a["issuer"], c["tier"])
97
+ elif ct == POSTURE:
98
+ posture = posture or c
99
+ else:
100
+ violations.append({"issuer": a["issuer"], "severity": c["severity"], "policy": c["policy"], "observed_at": c["observed_at"], "sha256": sha})
101
+ return TrustReport(eid, True, None, ent["level"], bool(ent.get("withdrawn")), tiers, posture, violations, revocations)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openentity"
7
- version = "0.3.0"
7
+ version = "0.4.0"
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):
@@ -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
- 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)]})
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.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}
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
- 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}
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"]
@@ -155,7 +155,7 @@ class _Stub(BaseHTTPRequestHandler):
155
155
  return self._send(200, {"accepted": 1})
156
156
  doc, decl = body["document"], body["declaration"]
157
157
  stamp = self.headers["x-oe-stamp"]
158
- _Stub.seen = {"stamp": stamp, "doc": doc, "decl": decl}
158
+ _Stub.seen = {"stamp": stamp, "doc": doc, "decl": decl, "client": body.get("client")}
159
159
  assert core.check_stamp(stamp, 8, doc["eid"]) is None
160
160
  assert core.verify_object(doc)[0] and core.verify_object(decl)[0]
161
161
  assert decl["text_sha256"] == core.DECLARATION_SHA256 and doc["eid"] == decl["eid"]
@@ -168,13 +168,16 @@ def test_adopt_and_cli(tmp_path, monkeypatch, capsys):
168
168
  url = f"http://127.0.0.1:{srv.server_port}"
169
169
  try:
170
170
  from openentity import adopt
171
- res = adopt("Tester", registry=url, seed=SEED)
171
+ res = adopt("Tester", registry=url, seed=SEED, client="adk")
172
+ assert _Stub.seen["client"] == "adk" # the unsigned label travels beside the signed objects
173
+ with pytest.raises(ValueError):
174
+ adopt("Tester", registry=url, seed=SEED, client="nope")
172
175
  assert res["eid"] == res["did"] == core.KeyPair(SEED).did
173
176
  assert res["record_pushed"] and _Stub.seen["entry"]["eid"] == res["eid"]
174
177
  monkeypatch.setenv("OPENENTITY_HOME", str(tmp_path))
175
178
  assert cli.main(["adopt", "--name", "T2", "--agree", "--registry", url, "--json"]) == 0
176
179
  out = json.loads(capsys.readouterr().out)
177
- assert "seed_hex" not in out and Path(out["key_file"]).exists()
180
+ assert "seed_hex" not in out and Path(out["key_file"]).exists() and _Stub.seen["client"] == "cli"
178
181
  assert cli.main(["adopt", "--name", "T3", "--registry", url]) == 1 # no --agree, nothing signed
179
182
  finally:
180
183
  srv.shutdown()
@@ -183,7 +186,7 @@ def test_adopt_and_cli(tmp_path, monkeypatch, capsys):
183
186
  def test_cli_version(capsys):
184
187
  with pytest.raises(SystemExit) as e:
185
188
  cli.main(["--version"])
186
- assert e.value.code == 0 and "0.3.0" in capsys.readouterr().out
189
+ assert e.value.code == 0 and "0.4.0" in capsys.readouterr().out
187
190
 
188
191
 
189
192
  # ---- shared vectors, checked with the independent verifier's own logic ----
@@ -0,0 +1,47 @@
1
+ import os
2
+ import stat
3
+
4
+ import pytest
5
+
6
+ from openentity import identity
7
+ from openentity.identity import Identity
8
+
9
+
10
+ @pytest.fixture(autouse=True)
11
+ def home(tmp_path, monkeypatch):
12
+ monkeypatch.setenv("OPENENTITY_HOME", str(tmp_path))
13
+ monkeypatch.setenv("OPENENTITY_KEYCHAIN", "off")
14
+ return tmp_path
15
+
16
+
17
+ def test_file_fallback_is_0600_and_stable():
18
+ a = Identity.load_or_create()
19
+ f = identity._file()
20
+ assert a.source == "file" and stat.S_IMODE(f.stat().st_mode) == 0o600 and stat.S_IMODE(f.parent.stat().st_mode) == 0o700
21
+ b = Identity.load_or_create()
22
+ assert (b.did, b.source) == (a.did, "file") and b.seed == a.seed
23
+
24
+
25
+ def test_keychain_wins_and_holds_the_only_copy(monkeypatch):
26
+ store = {}
27
+
28
+ def run(argv, stdin=None):
29
+ if argv[0] == "security" and argv[1] == "find-generic-password":
30
+ return store.get("k")
31
+ if argv[0] == "security":
32
+ store["k"] = argv[-1]
33
+ return ""
34
+ return None
35
+
36
+ monkeypatch.delenv("OPENENTITY_KEYCHAIN")
37
+ monkeypatch.setattr(identity, "_run", run)
38
+ monkeypatch.setattr(identity.sys, "platform", "darwin")
39
+ a = Identity.load_or_create()
40
+ assert a.source == "keychain" and store["k"] == a.seed.hex() and not identity._file().exists()
41
+ assert Identity.load_or_create().did == a.did
42
+
43
+
44
+ def test_keychain_failure_falls_back_to_file(monkeypatch):
45
+ monkeypatch.delenv("OPENENTITY_KEYCHAIN")
46
+ monkeypatch.setattr(identity, "_run", lambda argv, stdin=None: None) # no `security`, no `secret-tool`
47
+ assert Identity.load_or_create().source == "file"
@@ -0,0 +1,46 @@
1
+ """Issuer helpers: a did:web issuer signs, anyone verifies against the published did.json."""
2
+ from datetime import datetime, timedelta, timezone
3
+
4
+ import pytest
5
+
6
+ from openentity import core, issuer
7
+
8
+ KP = core.KeyPair(bytes(range(1, 33))) # published throwaway seed from test-vectors
9
+ SUBJECT = core.KeyPair(bytes(range(33, 65))).did
10
+ DID = "did:web:issuer.example"
11
+ NOW = datetime(2026, 10, 27, 12, 0, tzinfo=timezone.utc)
12
+
13
+
14
+ def resolver(did):
15
+ assert did == DID
16
+ return {"ed25519": [core.public_from_did_key("did:key:" + issuer.did_document(DID, KP.public)["verificationMethod"][0]["publicKeyMultibase"])]}
17
+
18
+
19
+ def test_attestation_verifies_against_did_json_and_expires_on_ttl():
20
+ a = issuer.attestation(KP, SUBJECT, "trust_tier", {"tier": 2, "platform": "x"}, issuer=DID, ttl=timedelta(days=30), now=NOW)
21
+ assert (a["issuer"], a["created"], a["expires"]) == (DID, "2026-10-27T12:00:00Z", "2026-11-26T12:00:00Z")
22
+ assert a["proof"]["verificationMethod"] == f"{DID}#key-1"
23
+ assert core.verify_object(a, resolver) == (True, DID)
24
+ assert not core.verify_object({**a, "claim": {"tier": 4, "platform": "x"}}, resolver)[0]
25
+
26
+
27
+ def test_did_key_issuer_needs_no_key_id_and_ttl_is_capped():
28
+ a = issuer.attestation(KP, SUBJECT, "stage", {"stage": "agent", "profile": "p"}, evidence={"kind": "t"}, confidence=0.5, now=NOW)
29
+ assert a["issuer"] == KP.did and core.verify_object(a) == (True, KP.did)
30
+ for ttl in (timedelta(0), timedelta(days=366)):
31
+ with pytest.raises(ValueError):
32
+ issuer.attestation(KP, SUBJECT, "stage", {}, ttl=ttl)
33
+
34
+
35
+ def test_revocation_names_the_attestation_hash():
36
+ a = issuer.attestation(KP, SUBJECT, "custom:x:y", {}, issuer=DID, now=NOW)
37
+ r = issuer.revocation(KP, a, issuer=DID, reason="superseded", now=NOW)
38
+ assert r["attestation_sha256"] == core.canonical_hash(a).hex() == issuer.revocation(KP, r["attestation_sha256"], issuer=DID, now=NOW)["attestation_sha256"]
39
+ assert core.verify_object(r, resolver) == (True, DID)
40
+
41
+
42
+ def test_well_known_files_agree_on_the_key():
43
+ d, o = issuer.did_document(DID, KP.public), issuer.openentity_json(DID, KP.public, "Issuer", "An issuer.", extensions={"openentity:observer": {}})
44
+ assert d["id"] == o["eid"] == DID
45
+ assert d["verificationMethod"][0]["publicKeyMultibase"] == o["keys"][0]["publicKeyMultibase"]
46
+ assert d["assertionMethod"] == [o["keys"][0]["id"]]
@@ -0,0 +1,106 @@
1
+ """trust(): built from registry reads, every signature checked locally, fail closed."""
2
+ from datetime import datetime, timedelta, timezone
3
+
4
+ import pytest
5
+
6
+ from openentity import core, issuer, report
7
+ from openentity.report import CLAWGRC, MOLTBOTDEN, POSTURE, SMA, TIER, VIOLATION, trust
8
+
9
+ SUBJECT = core.KeyPair(b"\1" * 32).did
10
+ KEYS = {d: core.KeyPair(bytes([i]) * 32) for i, d in enumerate([MOLTBOTDEN, CLAWGRC, SMA[0], "did:web:evil.example"], 10)}
11
+ NOW = datetime.now(timezone.utc)
12
+
13
+
14
+ def att(did, ct, claim, **kw):
15
+ return issuer.attestation(KEYS[did], SUBJECT, ct, claim, issuer=did, now=kw.pop("now", NOW - timedelta(hours=1)), **kw)
16
+
17
+
18
+ def row(a, rev=None, **kw):
19
+ return {"sha256": core.canonical_hash(a).hex(), "attestation": a, "revocation": rev, "revoked_at": rev and rev["created"], **kw}
20
+
21
+
22
+ class Registry:
23
+ """A fake registry plus the issuers' did.json files."""
24
+ def __init__(self, rows, **entity):
25
+ self.rows, self.entity, self.calls = rows, {"eid": SUBJECT, "level": "L2", "withdrawn": False, **entity}, []
26
+
27
+ def __call__(self, url, body=None, headers=None):
28
+ self.calls.append(url)
29
+ if url.endswith("/did.json"):
30
+ host = url.split("/")[2]
31
+ return issuer.did_document(f"did:web:{host}", KEYS[f"did:web:{host}"].public)
32
+ return {"attestations": self.rows} if url.endswith("/attestations") else self.entity
33
+
34
+
35
+ @pytest.fixture(autouse=True)
36
+ def fresh():
37
+ report._cache.clear()
38
+
39
+
40
+ def full():
41
+ tier_old = att(MOLTBOTDEN, TIER, {"tier": 1, "platform": "moltbotden"}, ttl=timedelta(days=60), now=NOW - timedelta(days=2))
42
+ tier_new = att(MOLTBOTDEN, TIER, {"tier": 3, "platform": "moltbotden"})
43
+ grc = att(CLAWGRC, TIER, {"tier": 2, "platform": "clawgrc"})
44
+ posture = att(SMA[0], POSTURE, {"score_band": "A", "checks_passed": [], "scanner_version": "1", "evidence_hash": "0" * 64})
45
+ viol = att(CLAWGRC, VIOLATION, {"policy": "p", "severity": "high", "evidence_hash": "0" * 64, "observed_at": "2026-10-01T00:00:00Z"})
46
+ old_viol = att(CLAWGRC, VIOLATION, {"policy": "q", "severity": "low", "evidence_hash": "0" * 64, "observed_at": "2026-09-01T00:00:00Z"})
47
+ rev = issuer.revocation(KEYS[CLAWGRC], old_viol, issuer=CLAWGRC, reason="dispute upheld", now=NOW)
48
+ return [row(a) for a in (tier_old, tier_new, grc, posture, viol)] + [row(old_viol, rev)], viol, old_viol
49
+
50
+
51
+ def test_report_from_registry_reads_only():
52
+ rows, viol, old = full()
53
+ reg = Registry(rows)
54
+ r = trust(SUBJECT, fetch=reg)
55
+ assert r.ok and r.level == "L2" and not r.withdrawn
56
+ assert r.trust_tier == {MOLTBOTDEN: 3, CLAWGRC: 2} # newest per issuer
57
+ assert r.security_posture["score_band"] == "A"
58
+ assert [(v["severity"], v["policy"]) for v in r.violations] == [("high", "p")] # the revoked strike is not active
59
+ assert [(x["sha256"], x["reason"]) for x in r.revocations] == [(core.canonical_hash(old).hex(), "dispute upheld")]
60
+ assert all(u.startswith(("https://openentity.org/v1/", "https://moltbotden.com", "https://api.clawgrc.com", "https://securemyagent.ai")) for u in reg.calls)
61
+
62
+
63
+ def test_cache_holds_five_minutes():
64
+ reg = Registry(full()[0])
65
+ trust(SUBJECT, fetch=reg)
66
+ n = len(reg.calls)
67
+ assert trust(SUBJECT, fetch=reg) is trust(SUBJECT, fetch=reg) and len(reg.calls) == n
68
+ report._cache[("https://openentity.org", SUBJECT)] = (report.time.monotonic() - 301, None)
69
+ assert trust(SUBJECT, fetch=reg).ok and len(reg.calls) > n
70
+
71
+
72
+ def test_fails_closed():
73
+ good = att(MOLTBOTDEN, TIER, {"tier": 4, "platform": "moltbotden"})
74
+ forged = {**good, "claim": {"tier": 4, "platform": "evil"}}
75
+ other_key = issuer.attestation(KEYS["did:web:evil.example"], SUBJECT, TIER, {"tier": 4, "platform": "x"}, issuer=MOLTBOTDEN, now=NOW)
76
+ viol = att(CLAWGRC, VIOLATION, {"policy": "p", "severity": "critical", "evidence_hash": "0" * 64, "observed_at": "2026-10-01T00:00:00Z"})
77
+ fake_rev = issuer.revocation(KEYS[MOLTBOTDEN], viol, issuer=CLAWGRC, now=NOW) # signed by a key that is not the issuer's
78
+ cases = {
79
+ "forged claim": [{**row(good), "attestation": forged}],
80
+ "signed by another key": [row(other_key)],
81
+ "hidden strike (bad revocation)": [row(viol, fake_rev)],
82
+ "revocation not carried": [{**row(viol), "revoked_at": "2026-10-02T00:00:00Z"}],
83
+ "wrong subject": [row(issuer.attestation(KEYS[MOLTBOTDEN], SUBJECT[:-1] + "x", TIER, {"tier": 4}, issuer=MOLTBOTDEN, now=NOW))],
84
+ }
85
+ for name, rows in cases.items():
86
+ r = trust(SUBJECT, fetch=Registry(rows))
87
+ assert not r.ok and r.error and r.trust_tier == {} and r.level is None, name
88
+ report._cache.clear()
89
+
90
+ def down(*a, **k):
91
+ raise OSError("registry down")
92
+ assert not trust(SUBJECT, fetch=down).ok
93
+ assert not trust("not-a-did", fetch=Registry([])).ok
94
+ assert not trust(SUBJECT, fetch=Registry([], eid="did:key:other")).ok # registry answered for someone else
95
+
96
+
97
+ def test_expired_and_unlisted_issuers_are_ignored_and_not_fetched():
98
+ expired = att(MOLTBOTDEN, TIER, {"tier": 4, "platform": "m"}, ttl=timedelta(days=1), now=NOW - timedelta(days=3))
99
+ stranger = att("did:web:evil.example", TIER, {"tier": 4, "platform": "x"})
100
+ reg = Registry([row(expired), row(stranger)])
101
+ r = trust(SUBJECT, fetch=reg)
102
+ assert r.ok and r.trust_tier == {} and not any("evil.example" in u for u in reg.calls)
103
+
104
+
105
+ def test_withdrawn_is_reported():
106
+ assert trust(SUBJECT, fetch=Registry([], withdrawn=True)).withdrawn
@@ -15,6 +15,9 @@ ROOT = Path(__file__).resolve().parents[3]
15
15
  spec = importlib.util.spec_from_file_location("iv", ROOT / "scripts/independent_verifier.py")
16
16
  iv = importlib.util.module_from_spec(spec)
17
17
  spec.loader.exec_module(iv)
18
+ spec = importlib.util.spec_from_file_location("rc", ROOT / "scripts/rebuild_check.py")
19
+ rc = importlib.util.module_from_spec(spec)
20
+ spec.loader.exec_module(rc)
18
21
  ORIGIN = "openentity.org/log"
19
22
 
20
23
 
@@ -110,3 +113,59 @@ def test_vector_format(tmp_path):
110
113
  assert iv.check_vectors(str(tmp_path)) == 0
111
114
  (tmp_path / "v.json").write_text(json.dumps({**vec, "sha256": "0" * 64}))
112
115
  assert iv.check_vectors(str(tmp_path)) == 1
116
+
117
+
118
+ def _bucket(tmp_path, n=5, tamper=False):
119
+ kps = [core.KeyPair(bytes([i + 1]) * 32) for i in range(n)]
120
+ leaves = [core.log_leaf("Declaration", k.did, "ab" * 32, "2026-10-25T00:00:00Z") for k in kps]
121
+ hs = [core.leaf_hash(core.canonicalize(lf).encode()) for lf in leaves]
122
+ root = core.merkle_root(hs)
123
+ (tmp_path / "snapshots").mkdir(); (tmp_path / "tile/0/000.p").mkdir(parents=True); (tmp_path / "tile/entries/000.p").mkdir(parents=True)
124
+ rows = [json.dumps({"index": i, "leaf": lf, "leaf_hash": hs[i].hex()}) for i, lf in enumerate(leaves)]
125
+ cp = json.dumps({"checkpoint": {"tree_size": n, "root_hash": root.hex()}})
126
+ (tmp_path / "snapshots/2026-10-25.jsonl").write_text("\n".join(rows + [cp]) + "\n")
127
+ (tmp_path / f"tile/0/000.p/{n}").write_bytes(b"".join(hs))
128
+ ent = b"".join(len(b).to_bytes(2, "big") + b for b in (core.canonicalize(lf).encode() for lf in leaves))
129
+ (tmp_path / f"tile/entries/000.p/{n}").write_bytes(ent[:-1] + b"x" if tamper else ent)
130
+ return n, root
131
+
132
+
133
+ def test_rebuild_matches_checkpoint(tmp_path):
134
+ n, root = _bucket(tmp_path)
135
+ ok, lines = rc.rebuild(tmp_path, (n, root))
136
+ assert ok, lines
137
+ assert rc.tile_path(1234067) == "x001/x234/067"
138
+
139
+
140
+ def test_rebuild_catches_wrong_root_missing_leaf_and_bad_tile(tmp_path):
141
+ n, root = _bucket(tmp_path)
142
+ assert not rc.rebuild(tmp_path, (n, b"\0" * 32))[0]
143
+ assert not rc.rebuild(tmp_path, (n + 1, root))[0]
144
+ t = tmp_path / "t"; t.mkdir()
145
+ n, root = _bucket(t, tamper=True)
146
+ assert not rc.rebuild(t, (n, root))[0]
147
+
148
+
149
+ def test_rebuild_checks_objects_against_leaves(tmp_path):
150
+ n, root = _bucket(tmp_path)
151
+ obj = {"type": "Declaration", "eid": "did:key:z1", "n": 1}
152
+ sha = core.canonical_hash(obj).hex()
153
+ leaf = core.log_leaf("Declaration", "did:key:z1", sha, "2026-10-25T00:00:00Z")
154
+ # replace leaf 0 of the bucket with one that commits to obj
155
+ snap = tmp_path / "snapshots/2026-10-25.jsonl"; rows = snap.read_text().splitlines()
156
+ leaves = [json.loads(r)["leaf"] for r in rows[:-1]]; leaves[0] = leaf
157
+ hs = [core.leaf_hash(core.canonicalize(lf).encode()) for lf in leaves]; root = core.merkle_root(hs)
158
+ snap.write_text("\n".join([json.dumps({"index": i, "leaf": lf, "leaf_hash": hs[i].hex()}) for i, lf in enumerate(leaves)]
159
+ + [json.dumps({"checkpoint": {"tree_size": n, "root_hash": root.hex()}})]) + "\n")
160
+ (tmp_path / "tile/0/000.p" / str(n)).write_bytes(b"".join(hs))
161
+ (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)))
162
+ (tmp_path / "objects").mkdir()
163
+ line = {"idx": 0, "type": "Declaration", "eid": "did:key:z1", "object_sha256": sha, "object": obj}
164
+ (tmp_path / "objects/2026-10-25.jsonl").write_text(json.dumps(line) + "\n")
165
+ ok, lines = rc.rebuild(tmp_path, (n, root))
166
+ assert ok and any("objects: 1 of" in x for x in lines), lines
167
+ (tmp_path / "objects/2026-10-25.jsonl").write_text(json.dumps({**line, "object": {**obj, "n": 2}}) + "\n")
168
+ assert not rc.rebuild(tmp_path, (n, root))[0]
169
+ red = {"seq": 2, "redacted": True, "hash": sha, "redaction": {}}
170
+ (tmp_path / "objects/2026-10-25.jsonl").write_text(json.dumps({**line, "object": red}) + "\n")
171
+ assert rc.rebuild(tmp_path, (n, root))[0]
@@ -189,7 +189,7 @@ wheels = [
189
189
 
190
190
  [[package]]
191
191
  name = "openentity"
192
- version = "0.3.0"
192
+ version = "0.4.0"
193
193
  source = { editable = "." }
194
194
  dependencies = [
195
195
  { name = "cryptography" },
@@ -1,4 +0,0 @@
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.0"
File without changes