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.
- {openentity-0.3.0 → openentity-0.4.0}/PKG-INFO +4 -2
- {openentity-0.3.0 → openentity-0.4.0}/README.md +3 -1
- openentity-0.4.0/openentity/__init__.py +7 -0
- {openentity-0.3.0 → openentity-0.4.0}/openentity/cli.py +33 -4
- {openentity-0.3.0 → openentity-0.4.0}/openentity/client.py +7 -3
- {openentity-0.3.0 → openentity-0.4.0}/openentity/core.py +26 -4
- openentity-0.4.0/openentity/identity.py +72 -0
- openentity-0.4.0/openentity/issuer.py +61 -0
- openentity-0.4.0/openentity/report.py +101 -0
- {openentity-0.3.0 → openentity-0.4.0}/pyproject.toml +1 -1
- {openentity-0.3.0 → openentity-0.4.0}/tests/test_client.py +57 -4
- {openentity-0.3.0 → openentity-0.4.0}/tests/test_core.py +7 -4
- openentity-0.4.0/tests/test_identity.py +47 -0
- openentity-0.4.0/tests/test_issuer.py +46 -0
- openentity-0.4.0/tests/test_trust.py +106 -0
- {openentity-0.3.0 → openentity-0.4.0}/tests/test_verifier.py +59 -0
- {openentity-0.3.0 → openentity-0.4.0}/uv.lock +1 -1
- openentity-0.3.0/openentity/__init__.py +0 -4
- {openentity-0.3.0 → openentity-0.4.0}/.gitignore +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: openentity
|
|
3
|
-
Version: 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
|
|
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
|
|
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
|
|
224
|
+
if e.get("redacted"):
|
|
225
|
+
if not re.fullmatch(r"[0-9a-f]{64}", e.get("hash") or ""):
|
|
226
|
+
why.append("placeholder hash is not SHA-256 hex")
|
|
227
|
+
if r := e.get("redaction"): # the entity's signed request for this entry
|
|
228
|
+
ok, who = core.verify_object(r)
|
|
229
|
+
if not (ok and who == eid and r.get("eid") == eid and r.get("seq") == e["seq"] and r.get("entry_sha256") == e.get("hash")):
|
|
230
|
+
why.append("redaction is not the entity's signed request for this entry")
|
|
231
|
+
else:
|
|
224
232
|
if e.get("eid") != eid:
|
|
225
233
|
why.append("eid mismatch")
|
|
226
234
|
ok, err = core.verify_object(e)
|
|
@@ -232,7 +240,27 @@ def _chain_checks(entries: list[dict], eid: str) -> list[dict]:
|
|
|
232
240
|
return bad + [{"name": f"record chain ({len(entries)} entries)", "ok": not bad}]
|
|
233
241
|
|
|
234
242
|
|
|
235
|
-
|
|
243
|
+
def _log_checks(reg: str, index: int | None) -> list[dict]:
|
|
244
|
+
"""Checkpoint note signature against the registry key from /v1/spec, then the RFC 9162 inclusion proof of `index`."""
|
|
245
|
+
if index is None:
|
|
246
|
+
return [{"name": "log inclusion", "ok": False, "detail": "registry did not report a log index"}]
|
|
247
|
+
try:
|
|
248
|
+
r = _get(reg, "/v1/spec").get("registry") or {}
|
|
249
|
+
sth = _get(reg, "/v1/log/sth")
|
|
250
|
+
if not r.get("publicKeyMultibase"):
|
|
251
|
+
return [{"name": "checkpoint", "ok": False, "detail": "registry publishes no checkpoint key in /v1/spec"}]
|
|
252
|
+
ok = core.verify_note(sth["signature"], r.get("log_origin", "openentity.org/log"), core.b58decode(r["publicKeyMultibase"][1:])[-32:])
|
|
253
|
+
out = [{"name": "checkpoint", "ok": ok, "detail": f"checkpoint signature valid, tree size {sth['tree_size']}" if ok else "checkpoint signature INVALID"}]
|
|
254
|
+
size = sth["tree_size"]
|
|
255
|
+
leaf = _get(reg, f"/v1/log/entries?start={index}&end={index + 1}")["entries"][0]["leaf"]
|
|
256
|
+
p = _get(reg, f"/v1/log/proof/{index}?tree_size={size}")
|
|
257
|
+
lh = core.leaf_hash(core.canonicalize(leaf).encode())
|
|
258
|
+
good = ok and lh.hex() == p["leaf_hash"] and core.verify_inclusion(index, size, lh, [bytes.fromhex(h) for h in p["proof"]], bytes.fromhex(sth["root_hash"]))
|
|
259
|
+
return out + [{"name": "log inclusion", "ok": good, "detail": f"leaf {index} included in tree of {size}" if good else f"inclusion proof for leaf {index} FAILED"}]
|
|
260
|
+
except (client.RegistryError, urllib.error.URLError, KeyError, IndexError, ValueError, TypeError) as e:
|
|
261
|
+
return [{"name": "log inclusion", "ok": False, "detail": f"{type(e).__name__}: {e}"}]
|
|
262
|
+
|
|
263
|
+
|
|
236
264
|
def _verify(args):
|
|
237
265
|
t, reg = args.target, _registry(args)
|
|
238
266
|
if t.startswith(("did:", "eid:")):
|
|
@@ -244,6 +272,7 @@ def _verify(args):
|
|
|
244
272
|
while page := _get(reg, f"/v1/entities/{eid}/record?after={entries[-1]['seq'] if entries else 0}")["entries"]:
|
|
245
273
|
entries += page
|
|
246
274
|
checks += _chain_checks(entries, eid)
|
|
275
|
+
checks += _log_checks(reg, ent.get("log_index", ent.get("declaration_log_index")))
|
|
247
276
|
elif t.startswith(("http://", "https://")):
|
|
248
277
|
checks = _checks_of(client._request(t))
|
|
249
278
|
else:
|
|
@@ -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)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
"""withdraw, record, dispute, find, present against a stub registry (same pattern as test_core.py)."""
|
|
2
|
+
import base64
|
|
2
3
|
import json
|
|
3
4
|
import threading
|
|
4
5
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
@@ -11,6 +12,8 @@ from openentity import cli, client, core
|
|
|
11
12
|
SEED = bytes.fromhex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60") # RFC 8032 test 1, public
|
|
12
13
|
SUBJECT = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
|
|
13
14
|
BITS = 8
|
|
15
|
+
ORIGIN = "openentity.org/log"
|
|
16
|
+
LOG_KEY = core.KeyPair(b"\7" * 32) # throwaway registry checkpoint key
|
|
14
17
|
|
|
15
18
|
|
|
16
19
|
class Stub(BaseHTTPRequestHandler):
|
|
@@ -19,6 +22,8 @@ class Stub(BaseHTTPRequestHandler):
|
|
|
19
22
|
attestations: list = []
|
|
20
23
|
entity: dict | None = None # overrides GET /v1/entities/<eid>
|
|
21
24
|
refuse = False # refuse every continuity link
|
|
25
|
+
leaves: list = [] # the stub log's leaf objects; the checkpoint is signed by LOG_KEY
|
|
26
|
+
forge_note = False
|
|
22
27
|
|
|
23
28
|
def log_message(self, *a):
|
|
24
29
|
pass
|
|
@@ -35,7 +40,23 @@ class Stub(BaseHTTPRequestHandler):
|
|
|
35
40
|
u = urlparse(self.path)
|
|
36
41
|
Stub.log.append(("GET", self.path, None, None))
|
|
37
42
|
if u.path == "/v1/spec":
|
|
38
|
-
|
|
43
|
+
reg = {"log_origin": ORIGIN, "publicKeyMultibase": "z" + core.b58encode(b"\xed\x01" + LOG_KEY.public)}
|
|
44
|
+
return self._send(200, {"spec": core.SPEC, "stamp_bits": BITS, "registry": reg})
|
|
45
|
+
hs = [core.leaf_hash(core.canonicalize(lf).encode()) for lf in Stub.leaves]
|
|
46
|
+
if u.path == "/v1/log/sth":
|
|
47
|
+
root = core.merkle_root(hs)
|
|
48
|
+
body = f"{ORIGIN}\n{len(hs)}\n{base64.b64encode(root).decode()}\n"
|
|
49
|
+
keyid = core.sha256(ORIGIN.encode() + b"\n\x01" + LOG_KEY.public)[:4]
|
|
50
|
+
sig = base64.b64encode(keyid + (KP if Stub.forge_note else LOG_KEY).private.sign(body.encode())).decode()
|
|
51
|
+
return self._send(200, {"tree_size": len(hs), "root_hash": root.hex(), "signature": f"{body}\n\u2014 {ORIGIN} {sig}\n"})
|
|
52
|
+
if u.path == "/v1/log/entries":
|
|
53
|
+
i = int(parse_qs(u.query)["start"][0])
|
|
54
|
+
if i >= len(hs):
|
|
55
|
+
return self._send(200, {"entries": []})
|
|
56
|
+
return self._send(200, {"entries": [{"index": i, "leaf": Stub.leaves[i], "leaf_hash": hs[i].hex()}]})
|
|
57
|
+
if u.path.startswith("/v1/log/proof/"):
|
|
58
|
+
i = int(u.path.rsplit("/", 1)[1])
|
|
59
|
+
return self._send(200, {"leaf_hash": hs[i].hex(), "proof": [h.hex() for h in core.inclusion_proof(i, hs)]})
|
|
39
60
|
if u.path == "/v1/entities":
|
|
40
61
|
return self._send(200, {"entities": [{"eid": SUBJECT, "name": "N", "level": "L0"}], "page": 0, "next_page": None})
|
|
41
62
|
if u.path.endswith("/record"):
|
|
@@ -83,7 +104,7 @@ class Stub(BaseHTTPRequestHandler):
|
|
|
83
104
|
|
|
84
105
|
@pytest.fixture
|
|
85
106
|
def url():
|
|
86
|
-
Stub.log, Stub.chain, Stub.attestations, Stub.entity, Stub.refuse = [], [], [], None, False
|
|
107
|
+
Stub.log, Stub.chain, Stub.attestations, Stub.entity, Stub.refuse, Stub.leaves, Stub.forge_note = [], [], [], None, False, [], False
|
|
87
108
|
srv = HTTPServer(("127.0.0.1", 0), Stub)
|
|
88
109
|
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
89
110
|
yield f"http://127.0.0.1:{srv.server_port}"
|
|
@@ -215,11 +236,21 @@ def test_cli_verify_entity_file_and_failures(url, home, capsys, tmp_path):
|
|
|
215
236
|
decl = core.sign_object({"spec": core.SPEC, "type": "Declaration", "eid": KP.did, "declaration_version": "1.0",
|
|
216
237
|
"text_sha256": core.DECLARATION_SHA256, "name": "T", "created": core.now(), "nonce": core.nonce()}, KP)
|
|
217
238
|
doc = core.sign_object({"spec": core.SPEC, "eid": KP.did, "name": "T", "layers": {}, "keys": []}, KP)
|
|
218
|
-
Stub.
|
|
239
|
+
Stub.leaves = [{"type": "Declaration", "eid": f"did:key:z{i}", "object_sha256": "00" * 32, "received_at": core.now()} for i in range(5)]
|
|
240
|
+
Stub.entity = {"document": doc, "declaration": decl, "log_index": 3}
|
|
219
241
|
client.record(SEED, [("arc_opened", {"arc_id": "a", "title": "A"}), ("arc_opened", {"arc_id": "b", "title": "B"})], registry=url)
|
|
220
242
|
code, out, _ = run(capsys, "verify", KP.did, "--json", *reg)
|
|
221
243
|
res = json.loads(out)
|
|
222
|
-
|
|
244
|
+
names = [c["name"] for c in res["checks"]]
|
|
245
|
+
assert code == 0 and res["ok"] and "record chain (2 entries)" in names and "checkpoint" in names and "log inclusion" in names
|
|
246
|
+
Stub.forge_note = True # checkpoint signed by a key other than the registry's
|
|
247
|
+
assert run(capsys, "verify", KP.did, *reg)[0] == 3
|
|
248
|
+
Stub.forge_note = False
|
|
249
|
+
Stub.entity = {**Stub.entity, "log_index": 9} # no such leaf
|
|
250
|
+
assert run(capsys, "verify", KP.did, *reg)[0] == 3
|
|
251
|
+
Stub.entity = {**Stub.entity, "log_index": None}
|
|
252
|
+
assert "did not report a log index" in run(capsys, "verify", KP.did, *reg)[1]
|
|
253
|
+
Stub.entity = {**Stub.entity, "log_index": 3}
|
|
223
254
|
assert "VERIFIED" in run(capsys, "verify", KP.did, *reg)[1]
|
|
224
255
|
f = tmp_path / "decl.json"
|
|
225
256
|
f.write_text(json.dumps(decl))
|
|
@@ -293,3 +324,25 @@ def test_rotate_the_registry_refuses_raises_and_returns_nothing(url):
|
|
|
293
324
|
with pytest.raises(client.RegistryError) as e:
|
|
294
325
|
client.rotate(SEED, registry=url)
|
|
295
326
|
assert e.value.status == 422
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_verify_note_rejects_a_wrong_key_origin_or_body():
|
|
330
|
+
body = f"{ORIGIN}\n5\n{base64.b64encode(b'r' * 32).decode()}\n"
|
|
331
|
+
keyid = core.sha256(ORIGIN.encode() + b"\n\x01" + LOG_KEY.public)[:4]
|
|
332
|
+
note = f"{body}\n\u2014 {ORIGIN} {base64.b64encode(keyid + LOG_KEY.private.sign(body.encode())).decode()}\n"
|
|
333
|
+
assert core.verify_note(note, ORIGIN, LOG_KEY.public)
|
|
334
|
+
assert not core.verify_note(note, ORIGIN, KP.public)
|
|
335
|
+
assert not core.verify_note(note, "other/log", LOG_KEY.public)
|
|
336
|
+
assert not core.verify_note(note.replace("\n5\n", "\n6\n"), ORIGIN, LOG_KEY.public)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def test_chain_check_verifies_redaction_signatures():
|
|
340
|
+
e1 = core.sign_object({"spec": core.SPEC, "type": "RecordEntry", "eid": KP.did, "seq": 1, "prev": None, "kind": "arc_opened", "payload": {}, "created": core.now()}, KP)
|
|
341
|
+
h = core.canonical_hash(e1).hex()
|
|
342
|
+
red = core.sign_object({"spec": core.SPEC, "type": "Redaction", "eid": KP.did, "seq": 1, "entry_sha256": h, "created": core.now()}, KP)
|
|
343
|
+
ph = {"seq": 1, "prev": None, "redacted": True, "hash": h, "redaction": red}
|
|
344
|
+
assert cli._chain_checks([ph], KP.did)[-1]["ok"]
|
|
345
|
+
forged = core.sign_object({k: v for k, v in red.items() if k != "proof"}, core.KeyPair(b"\x05" * 32))
|
|
346
|
+
assert not cli._chain_checks([{**ph, "redaction": forged}], KP.did)[-1]["ok"]
|
|
347
|
+
assert not cli._chain_checks([{**ph, "redaction": {**red, "seq": 2}}], KP.did)[-1]["ok"]
|
|
348
|
+
assert not cli._chain_checks([{**ph, "hash": "zz"}], KP.did)[-1]["ok"]
|
|
@@ -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.
|
|
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]
|
|
File without changes
|