openentity 0.3.1__tar.gz → 0.5.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.1
3
+ Version: 0.5.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
@@ -41,4 +41,8 @@ Exit codes match the npm CLI: 0 ok, 1 usage, 2 network, 3 verification failed, 4
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
+
46
+ HTTP message signatures (RFC 9421, Web Bot Auth profile): `sign_request(method, url, body, private_key, did)` returns `Signature-Input`, `Signature` and, for a non-empty body, `Content-Digest` (`private_key` is an `Identity`, a `KeyPair` or a 32-byte seed; ed25519, `keyid` = the signer's did:key, covers `@method`, `@target-uri`, `content-digest`; `created`, `expires`, `nonce`). `verify_request(method, url, headers, body)` returns `{"did", "keyid", "created", "expires", "nonce"}` or raises `HttpSigError` with a `.code` (`missing_headers`, `unknown_alg`, `bad_keyid`, `digest_mismatch`, `created_in_future`, `created_too_old`, `expired`, `lifetime_too_long`, `missing_nonce`, `bad_signature`). Nonce replay tracking is yours. Shared vectors: `test-vectors/http-signature.json`.
47
+
44
48
  Written from the spec text alone, independent of the TypeScript SDK.
@@ -28,4 +28,8 @@ Exit codes match the npm CLI: 0 ok, 1 usage, 2 network, 3 verification failed, 4
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
+
33
+ HTTP message signatures (RFC 9421, Web Bot Auth profile): `sign_request(method, url, body, private_key, did)` returns `Signature-Input`, `Signature` and, for a non-empty body, `Content-Digest` (`private_key` is an `Identity`, a `KeyPair` or a 32-byte seed; ed25519, `keyid` = the signer's did:key, covers `@method`, `@target-uri`, `content-digest`; `created`, `expires`, `nonce`). `verify_request(method, url, headers, body)` returns `{"did", "keyid", "created", "expires", "nonce"}` or raises `HttpSigError` with a `.code` (`missing_headers`, `unknown_alg`, `bad_keyid`, `digest_mismatch`, `created_in_future`, `created_too_old`, `expired`, `lifetime_too_long`, `missing_nonce`, `bad_signature`). Nonce replay tracking is yours. Shared vectors: `test-vectors/http-signature.json`.
34
+
31
35
  Written from the spec text alone, independent of the TypeScript SDK.
@@ -0,0 +1,8 @@
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 .httpsig import HttpSigError, sign_request, verify_request # noqa: F401
5
+ from .identity import Identity # noqa: F401
6
+ from .report import TrustReport, trust # noqa: F401
7
+
8
+ __version__ = "0.5.0"
@@ -143,7 +143,7 @@ def _adopt(args):
143
143
  if not args.agree:
144
144
  raise CliError(EXIT_USAGE, "adopt signs the Open Entity Declaration. Read it, then re-run with --agree:\n\n" + core.DECLARATION_TEXT)
145
145
  reg = _registry(args)
146
- res = client.adopt(args.name, args.description, reg)
146
+ res = client.adopt(args.name, args.description, reg, client="cli")
147
147
  key_path = _save_key({**res, "name": args.name, "registry": reg})
148
148
  if not res["record_pushed"]:
149
149
  print("warning: the declaration record entry was not accepted; push it later", file=sys.stderr)
@@ -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.
@@ -140,15 +140,16 @@ def _signing_input(obj: dict, proof: dict) -> bytes:
140
140
  return canonical_hash(config) + canonical_hash(doc)
141
141
 
142
142
 
143
- 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`."""
144
145
  proof = {"type": "DataIntegrityProof", "cryptosuite": "eddsa-jcs-2022", "created": created or now(),
145
- "verificationMethod": verification_method(kp.did), "proofPurpose": "assertionMethod"}
146
+ "verificationMethod": method or verification_method(kp.did), "proofPurpose": "assertionMethod"}
146
147
  sig = kp.private.sign(_signing_input(obj, proof))
147
148
  return {**proof, "proofValue": "z" + b58encode(sig)}
148
149
 
149
150
 
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)}
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)}
152
153
 
153
154
 
154
155
  # ---- continuity (spec 9) ----
@@ -0,0 +1,110 @@
1
+ """RFC 9421 HTTP message signatures, Open Entity profile: ed25519, keyid = the signer's did:key,
2
+ covered ("@method" "@target-uri" "content-digest"), Content-Digest per RFC 9530, label sig1."""
3
+ import base64
4
+ import hashlib
5
+ import os
6
+ import re
7
+ import time
8
+ from collections.abc import Mapping
9
+
10
+ from cryptography.exceptions import InvalidSignature
11
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
12
+
13
+ from . import core
14
+
15
+ SKEW = 5 # seconds of clock skew allowed on `created`
16
+
17
+
18
+ class HttpSigError(Exception):
19
+ def __init__(self, code: str):
20
+ super().__init__(code)
21
+ self.code = code
22
+
23
+
24
+ def _b64url(b: bytes) -> str:
25
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
26
+
27
+
28
+ def _digest(body: bytes) -> str:
29
+ return f"sha-256=:{base64.b64encode(hashlib.sha256(body).digest()).decode()}:"
30
+
31
+
32
+ def _base(method: str, url: str, components: list[str], digest: str | None, params: str) -> bytes:
33
+ lines = []
34
+ for c in components:
35
+ v = {"@method": method.upper(), "@target-uri": url, "content-digest": digest}[c]
36
+ lines.append(f'"{c}": {v}')
37
+ lines.append(f'"@signature-params": {params}')
38
+ return "\n".join(lines).encode()
39
+
40
+
41
+ def _seed(key) -> bytes:
42
+ for attr in ("seed", "kp"): # Identity -> KeyPair -> seed
43
+ if hasattr(key, attr):
44
+ return _seed(getattr(key, attr))
45
+ if isinstance(key, (bytes, bytearray)) and len(key) == 32:
46
+ return bytes(key)
47
+ raise ValueError("private_key must be an Identity, a KeyPair or a 32-byte Ed25519 seed")
48
+
49
+
50
+ def sign_request(method: str, url: str, body: bytes, private_key, did: str, *, created: int | None = None,
51
+ expires_in: int = 60, nonce: str | None = None) -> dict[str, str]:
52
+ created = int(time.time()) if created is None else created
53
+ nonce = nonce or _b64url(os.urandom(16))
54
+ out = {}
55
+ comps = ["@method", "@target-uri"]
56
+ if body:
57
+ out["Content-Digest"] = _digest(body)
58
+ comps.append("content-digest")
59
+ params = f'({" ".join(chr(34) + c + chr(34) for c in comps)});created={created};expires={created + expires_in};nonce="{nonce}";keyid="{did}";alg="ed25519"'
60
+ sig = Ed25519PrivateKey.from_private_bytes(_seed(private_key)).sign(_base(method, url, comps, out.get("Content-Digest"), params))
61
+ out["Signature-Input"] = f"sig1={params}"
62
+ out["Signature"] = f"sig1=:{base64.b64encode(sig).decode()}:"
63
+ return out
64
+
65
+
66
+ def verify_request(method: str, url: str, headers: Mapping[str, str], body: bytes, *, now: int | None = None,
67
+ max_age: int = 300) -> dict:
68
+ h = {k.lower(): v for k, v in headers.items()}
69
+ if "signature-input" not in h or "signature" not in h:
70
+ raise HttpSigError("missing_headers")
71
+ m = re.search(r"(?:^|,)\s*sig1=(\(([^)]*)\)((?:;[a-z]+=(?:\d+|\"[^\"]*\"))*))", h["signature-input"])
72
+ s = re.search(r"(?:^|,)\s*sig1=:([A-Za-z0-9+/]+={0,2}):", h["signature"])
73
+ if not m or not s:
74
+ raise HttpSigError("missing_headers")
75
+ params, comps = m.group(1), re.findall(r'"([^"]*)"', m.group(2))
76
+ p = {k: (v[1:-1] if v.startswith('"') else int(v)) for k, v in re.findall(r";([a-z]+)=(\d+|\"[^\"]*\")", m.group(3))}
77
+ if p.get("alg") != "ed25519":
78
+ raise HttpSigError("unknown_alg")
79
+ keyid = p.get("keyid", "")
80
+ try:
81
+ public = core.public_from_did_key(keyid)
82
+ if core.did_key(public) != keyid:
83
+ raise ValueError
84
+ except Exception:
85
+ raise HttpSigError("bad_keyid") from None
86
+ if not (isinstance(p.get("created"), int) and isinstance(p.get("expires"), int)):
87
+ raise HttpSigError("missing_headers")
88
+ if len(p.get("nonce", "")) < 22: # 16 bytes in base64url
89
+ raise HttpSigError("missing_nonce")
90
+ if comps[:2] != ["@method", "@target-uri"] or comps[2:] not in ([], ["content-digest"]):
91
+ raise HttpSigError("missing_headers")
92
+ digest = None
93
+ if body or "content-digest" in comps:
94
+ digest = h.get("content-digest")
95
+ if "content-digest" not in comps or digest != _digest(body):
96
+ raise HttpSigError("digest_mismatch")
97
+ now = int(time.time()) if now is None else now
98
+ if p["created"] > now + SKEW:
99
+ raise HttpSigError("created_in_future")
100
+ if now - p["created"] > max_age:
101
+ raise HttpSigError("created_too_old")
102
+ if p["expires"] < now:
103
+ raise HttpSigError("expired")
104
+ if p["expires"] - p["created"] > max_age:
105
+ raise HttpSigError("lifetime_too_long")
106
+ try:
107
+ Ed25519PublicKey.from_public_bytes(public).verify(base64.b64decode(s.group(1)), _base(method, url, comps, digest, params))
108
+ except (InvalidSignature, ValueError):
109
+ raise HttpSigError("bad_signature") from None
110
+ return {"did": keyid, "keyid": keyid, "created": p["created"], "expires": p["expires"], "nonce": p["nonce"]}
@@ -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.1"
7
+ version = "0.5.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"
@@ -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.1" in capsys.readouterr().out
189
+ assert e.value.code == 0 and "0.5.0" in capsys.readouterr().out
187
190
 
188
191
 
189
192
  # ---- shared vectors, checked with the independent verifier's own logic ----
@@ -0,0 +1,116 @@
1
+ import base64
2
+ import json
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
7
+
8
+ from openentity import HttpSigError, Identity, core, sign_request, verify_request
9
+ from openentity.httpsig import _base, _digest
10
+
11
+ VEC = json.loads((Path(__file__).resolve().parents[3] / "test-vectors/http-signature.json").read_text())
12
+ SEED = bytes(range(1, 33)) # published throwaway seed, test-vectors/keys.json
13
+ KP = core.KeyPair(SEED)
14
+ URL, NOW, BODY = "https://gw.example.com/v1/x", 1_800_000_000, b'{"a":1}'
15
+ NONCE = "AAECAwQFBgcICQoLDA0ODw"
16
+
17
+
18
+ def req(**kw):
19
+ return sign_request("POST", URL, BODY, SEED, KP.did, created=NOW, nonce=NONCE, **kw)
20
+
21
+
22
+ def check(h, code, **kw):
23
+ with pytest.raises(HttpSigError) as e:
24
+ verify_request(kw.pop("method", "POST"), kw.pop("url", URL), h, kw.pop("body", BODY), now=kw.pop("now", NOW), **kw)
25
+ assert e.value.code == code
26
+
27
+
28
+ def forge(params, comps=("@method", "@target-uri", "content-digest"), body=BODY, key=SEED):
29
+ """A correctly signed request with arbitrary Signature-Input parameters."""
30
+ h = {"Content-Digest": _digest(body)} if body else {}
31
+ sig = Ed25519PrivateKey.from_private_bytes(key).sign(_base("POST", URL, list(comps), h.get("Content-Digest"), params))
32
+ return {**h, "Signature-Input": f"sig1={params}", "Signature": f"sig1=:{base64.b64encode(sig).decode()}:"}
33
+
34
+
35
+ def params(created=NOW, expires=NOW + 60, nonce=NONCE, keyid=KP.did, alg="ed25519", comps='"@method" "@target-uri" "content-digest"'):
36
+ n = f';nonce="{nonce}"' if nonce else ""
37
+ return f'({comps});created={created};expires={expires}{n};keyid="{keyid}";alg="{alg}"'
38
+
39
+
40
+ @pytest.mark.parametrize("case", VEC["valid"], ids=lambda c: c["name"])
41
+ def test_vectors_sign_and_verify(case):
42
+ h = sign_request(case["method"], case["url"], case["body"].encode(), SEED, VEC["did"], created=case["created"],
43
+ expires_in=case["expires_in"], nonce=case["nonce"])
44
+ assert h == case["headers"] # byte-identical to the TS SDK
45
+ out = verify_request(case["method"], case["url"], case["headers"], case["body"].encode(), now=VEC["now"])
46
+ assert out == {"did": VEC["did"], "keyid": VEC["did"], "created": case["created"], "expires": case["created"] + 60, "nonce": case["nonce"]}
47
+
48
+
49
+ @pytest.mark.parametrize("case", VEC["invalid"], ids=lambda c: c["name"])
50
+ def test_vectors_reject(case):
51
+ check(case["headers"], case["code"], method=case["method"], url=case["url"], body=case["body"].encode(), now=case.get("now", VEC["now"]))
52
+
53
+
54
+ def test_roundtrip_and_key_types():
55
+ assert verify_request("post", URL, req(), BODY, now=NOW)["did"] == KP.did
56
+ for key in (KP, Identity(SEED, "test")):
57
+ assert sign_request("POST", URL, BODY, key, KP.did, created=NOW, nonce=NONCE) == req()
58
+ h = sign_request("GET", URL, b"", SEED, KP.did)
59
+ assert "Content-Digest" not in h and "content-digest" not in h["Signature-Input"]
60
+ assert verify_request("GET", URL, h, b"")["did"] == KP.did
61
+
62
+
63
+ def test_header_lookup_is_case_insensitive():
64
+ h = {k.lower(): v for k, v in req().items()}
65
+ assert verify_request("POST", URL, h, BODY, now=NOW)
66
+
67
+
68
+ def test_missing_headers():
69
+ check({}, "missing_headers")
70
+ check({k: v for k, v in req().items() if k != "Signature"}, "missing_headers")
71
+ check({**req(), "Signature": "garbage"}, "missing_headers")
72
+
73
+
74
+ def test_unknown_alg():
75
+ check(forge(params(alg="rsa-pss-sha512")), "unknown_alg")
76
+
77
+
78
+ def test_bad_keyid():
79
+ check(forge(params(keyid="did:web:example.com")), "bad_keyid")
80
+ check(forge(params(keyid="did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme")), "bad_keyid") # secp256k1
81
+
82
+
83
+ def test_digest_mismatch():
84
+ check(req(), "digest_mismatch", body=b"other")
85
+ check({k: v for k, v in req().items() if k != "Content-Digest"}, "digest_mismatch")
86
+ check(forge(params(comps='"@method" "@target-uri"')), "digest_mismatch") # body present, digest not covered
87
+
88
+
89
+ def test_created_in_future_and_skew():
90
+ check(req(), "created_in_future", now=NOW - 6)
91
+ assert verify_request("POST", URL, req(), BODY, now=NOW - 5)
92
+
93
+
94
+ def test_created_too_old():
95
+ check(forge(params(created=NOW - 400, expires=NOW + 10)), "created_too_old")
96
+ check(req(), "created_too_old", now=NOW + 301, max_age=300)
97
+
98
+
99
+ def test_expired():
100
+ check(req(), "expired", now=NOW + 61)
101
+
102
+
103
+ def test_lifetime_too_long():
104
+ check(forge(params(expires=NOW + 301)), "lifetime_too_long")
105
+ check(sign_request("POST", URL, BODY, SEED, KP.did, created=NOW, nonce=NONCE, expires_in=200), "lifetime_too_long", max_age=100)
106
+
107
+
108
+ def test_missing_nonce():
109
+ check(forge(params(nonce=None)), "missing_nonce")
110
+ check(forge(params(nonce="short")), "missing_nonce")
111
+
112
+
113
+ def test_bad_signature():
114
+ check(req(), "bad_signature", url=URL + "?y=1")
115
+ check(req(), "bad_signature", method="GET")
116
+ check(forge(params(), key=bytes(32)), "bad_signature") # signed by another key
@@ -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
 
@@ -129,18 +132,18 @@ def _bucket(tmp_path, n=5, tamper=False):
129
132
 
130
133
  def test_rebuild_matches_checkpoint(tmp_path):
131
134
  n, root = _bucket(tmp_path)
132
- ok, lines = iv.rebuild(tmp_path, (n, root))
135
+ ok, lines = rc.rebuild(tmp_path, (n, root))
133
136
  assert ok, lines
134
- assert iv.tile_path(1234067) == "x001/x234/067"
137
+ assert rc.tile_path(1234067) == "x001/x234/067"
135
138
 
136
139
 
137
140
  def test_rebuild_catches_wrong_root_missing_leaf_and_bad_tile(tmp_path):
138
141
  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]
142
+ assert not rc.rebuild(tmp_path, (n, b"\0" * 32))[0]
143
+ assert not rc.rebuild(tmp_path, (n + 1, root))[0]
141
144
  t = tmp_path / "t"; t.mkdir()
142
145
  n, root = _bucket(t, tamper=True)
143
- assert not iv.rebuild(t, (n, root))[0]
146
+ assert not rc.rebuild(t, (n, root))[0]
144
147
 
145
148
 
146
149
  def test_rebuild_checks_objects_against_leaves(tmp_path):
@@ -159,10 +162,10 @@ def test_rebuild_checks_objects_against_leaves(tmp_path):
159
162
  (tmp_path / "objects").mkdir()
160
163
  line = {"idx": 0, "type": "Declaration", "eid": "did:key:z1", "object_sha256": sha, "object": obj}
161
164
  (tmp_path / "objects/2026-10-25.jsonl").write_text(json.dumps(line) + "\n")
162
- ok, lines = iv.rebuild(tmp_path, (n, root))
165
+ ok, lines = rc.rebuild(tmp_path, (n, root))
163
166
  assert ok and any("objects: 1 of" in x for x in lines), lines
164
167
  (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]
168
+ assert not rc.rebuild(tmp_path, (n, root))[0]
166
169
  red = {"seq": 2, "redacted": True, "hash": sha, "redaction": {}}
167
170
  (tmp_path / "objects/2026-10-25.jsonl").write_text(json.dumps({**line, "object": red}) + "\n")
168
- assert iv.rebuild(tmp_path, (n, root))[0]
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.1"
192
+ version = "0.5.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.1"
File without changes