openentity 0.1.0__tar.gz → 0.2.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.1.0
3
+ Version: 0.2.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
@@ -16,19 +16,29 @@ Description-Content-Type: text/markdown
16
16
  Python client for the Open Entity Identity Standard (spec id `OEIS-1`). One dependency: `cryptography`.
17
17
 
18
18
  ```
19
- uvx --from "git+https://github.com/moltbot-den/openentity#subdirectory=sdk/python" openentity adopt --name "My Agent" --agree
19
+ uvx openentity adopt --name "My Agent" --agree
20
20
  ```
21
21
 
22
- Commands (keys saved by `adopt` live in `~/.openentity/keys/`; pass `--eid` when there are several, `--registry URL` to change the registry):
22
+ Commands (keys saved by `adopt` live in `~/.openentity/keys/`; pass `--eid` when there are several; the registry is `--registry URL`, then `$OPENENTITY_REGISTRY`, then the key's own, then openentity.org). Every command takes `--json`:
23
23
 
24
24
  ```
25
+ openentity whoami
26
+ openentity self [--entries N]
27
+ openentity record stance --type refusal --reason values --cost low --context TEXT [--conflict]
28
+ openentity record update --topic T --from A --to B
29
+ openentity record arc open|close --id SLUG --title TEXT
30
+ openentity record collab --with EID --role R --outcome O
31
+ openentity record continuity --event rotation --from A --to B
25
32
  openentity record <kind> --payload '<json>'
26
- openentity dispute --subject EID [--object SHA256] --reason inaccurate --statement "..."
27
33
  openentity present --audience A --nonce N [--no-attestations]
34
+ openentity verify <eid|url|file>
35
+ openentity dispute --subject EID [--object SHA256] --reason inaccurate --statement "..."
28
36
  openentity find --capability C [-q TEXT --alias-type T --level L1]
29
37
  openentity withdraw --force
30
38
  ```
31
39
 
40
+ Exit codes match the npm CLI: 0 ok, 1 usage, 2 network, 3 verification failed, 4 registry rejected, 5 local state. `verify` checks signatures, the signer, the Declaration hash and the record chain, not the log checkpoint (use the npm CLI for that).
41
+
32
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`.
33
43
 
34
44
  Written from the spec text alone, independent of the TypeScript SDK.
@@ -3,19 +3,29 @@
3
3
  Python client for the Open Entity Identity Standard (spec id `OEIS-1`). One dependency: `cryptography`.
4
4
 
5
5
  ```
6
- uvx --from "git+https://github.com/moltbot-den/openentity#subdirectory=sdk/python" openentity adopt --name "My Agent" --agree
6
+ uvx openentity adopt --name "My Agent" --agree
7
7
  ```
8
8
 
9
- Commands (keys saved by `adopt` live in `~/.openentity/keys/`; pass `--eid` when there are several, `--registry URL` to change the registry):
9
+ Commands (keys saved by `adopt` live in `~/.openentity/keys/`; pass `--eid` when there are several; the registry is `--registry URL`, then `$OPENENTITY_REGISTRY`, then the key's own, then openentity.org). Every command takes `--json`:
10
10
 
11
11
  ```
12
+ openentity whoami
13
+ openentity self [--entries N]
14
+ openentity record stance --type refusal --reason values --cost low --context TEXT [--conflict]
15
+ openentity record update --topic T --from A --to B
16
+ openentity record arc open|close --id SLUG --title TEXT
17
+ openentity record collab --with EID --role R --outcome O
18
+ openentity record continuity --event rotation --from A --to B
12
19
  openentity record <kind> --payload '<json>'
13
- openentity dispute --subject EID [--object SHA256] --reason inaccurate --statement "..."
14
20
  openentity present --audience A --nonce N [--no-attestations]
21
+ openentity verify <eid|url|file>
22
+ openentity dispute --subject EID [--object SHA256] --reason inaccurate --statement "..."
15
23
  openentity find --capability C [-q TEXT --alias-type T --level L1]
16
24
  openentity withdraw --force
17
25
  ```
18
26
 
27
+ Exit codes match the npm CLI: 0 ok, 1 usage, 2 network, 3 verification failed, 4 registry rejected, 5 local state. `verify` checks signatures, the signer, the Declaration hash and the record chain, not the log checkpoint (use the npm CLI for that).
28
+
19
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`.
20
30
 
21
31
  Written from the spec text alone, independent of the TypeScript SDK.
@@ -1,4 +1,4 @@
1
1
  from .client import adopt, dispute, find, present, record, withdraw # noqa: F401
2
2
  from .core import * # noqa: F401,F403
3
3
 
4
- __version__ = "0.1.0"
4
+ __version__ = "0.2.0"
@@ -0,0 +1,294 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import pathlib
5
+ import sys
6
+ import urllib.error
7
+
8
+ from . import __version__, client, core
9
+
10
+ EXIT_OK, EXIT_USAGE, EXIT_NETWORK, EXIT_VERIFY, EXIT_REJECTED, EXIT_STATE = range(6) # same codes as the npm CLI
11
+
12
+
13
+ class CliError(Exception):
14
+ def __init__(self, code: int, message: str):
15
+ super().__init__(message)
16
+ self.code = code
17
+
18
+
19
+ def _keys_dir() -> pathlib.Path:
20
+ return pathlib.Path(os.environ.get("OPENENTITY_HOME", "~/.openentity")).expanduser() / "keys"
21
+
22
+
23
+ def _save_key(result: dict) -> pathlib.Path:
24
+ d = _keys_dir()
25
+ d.mkdir(parents=True, exist_ok=True, mode=0o700)
26
+ p = d / (result["did"].removeprefix("did:key:") + ".json")
27
+ p.write_text(json.dumps({k: result[k] for k in ("did", "seed_hex", "name", "registry") if k in result}))
28
+ p.chmod(0o600)
29
+ return p
30
+
31
+
32
+ def _load_key(eid: str | None) -> dict:
33
+ """The key saved by `adopt`: the one named by --eid (or OPENENTITY_EID), or the only one there is."""
34
+ eid = eid or os.environ.get("OPENENTITY_EID")
35
+ keys = [json.loads(p.read_text()) for p in sorted(_keys_dir().glob("*.json"))] if _keys_dir().is_dir() else []
36
+ keys = [k for k in keys if eid in (None, k["did"])]
37
+ if len(keys) != 1:
38
+ raise CliError(EXIT_STATE, f"expected one saved key{f' for {eid}' if eid else ' (run adopt first, or pass --eid)'}, found {len(keys)} in {_keys_dir()}")
39
+ return keys[0]
40
+
41
+
42
+ def _registry(args, key: dict | None = None) -> str:
43
+ return args.registry or os.environ.get("OPENENTITY_REGISTRY") or (key or {}).get("registry") or client.DEFAULT_REGISTRY
44
+
45
+
46
+ def _dump(o) -> str:
47
+ return json.dumps(o, indent=2)
48
+
49
+
50
+ def _get(reg: str, path: str) -> dict:
51
+ return client._request(f"{reg.rstrip('/')}{path}")
52
+
53
+
54
+ def _need(args, flags: dict) -> dict:
55
+ out = {}
56
+ for flag, (field, choices) in flags.items():
57
+ v = getattr(args, flag)
58
+ if v is None:
59
+ raise CliError(EXIT_USAGE, f"--{flag} is required")
60
+ if choices and v not in choices:
61
+ raise CliError(EXIT_USAGE, f"--{flag} must be one of: {', '.join(choices)}")
62
+ out[field] = v
63
+ return out
64
+
65
+
66
+ def _payload(kind: str, args) -> dict:
67
+ """Payload for a record kind from the npm CLI's flags (spec 7.2). The registry validates the rest."""
68
+ opt = lambda flag, field: {field: getattr(args, flag)} if getattr(args, flag) else {}
69
+ if kind == "stance":
70
+ return {**_need(args, {"type": ("stance_type", ("refusal", "correction", "limit_assertion")),
71
+ "reason": ("reason_category", ("values", "capability", "safety", "relationship")),
72
+ "cost": ("cost", ("none", "low", "high")), "context": ("context", None)}),
73
+ "instruction_conflict": args.conflict, **opt("will_not_ref", "will_not_ref")}
74
+ if kind == "belief_update":
75
+ return {**_need(args, {"topic": ("topic", None), "from": ("from_summary", None), "to": ("to_summary", None)}), **opt("evidence", "evidence_ref")}
76
+ if kind in ("arc_opened", "arc_closed"):
77
+ return {**_need(args, {"id": ("arc_id", None), "title": ("title", None)}), **(opt("outcome", "outcome") if kind == "arc_closed" else {})}
78
+ if kind == "collaboration":
79
+ return {**_need(args, {"with": ("with", None), "role": ("role", None), "outcome": ("outcome", None)}), **opt("ref", "ref")}
80
+ return {**_need(args, {"event": ("event", ("substrate_change", "migration", "rotation", "recovery")), "from": ("from", None), "to": ("to", None)}), **opt("link", "link")}
81
+
82
+
83
+ KINDS = {"stance": "stance", "update": "belief_update", "collab": "collaboration", "continuity": "continuity",
84
+ "belief_update": "belief_update", "arc_opened": "arc_opened", "arc_closed": "arc_closed", "collaboration": "collaboration"}
85
+
86
+
87
+ class _Parser(argparse.ArgumentParser):
88
+ def error(self, message): # usage errors exit 1, like the npm CLI (argparse defaults to 2)
89
+ self.print_usage(sys.stderr)
90
+ self.exit(EXIT_USAGE, f"openentity: {message}\n")
91
+
92
+
93
+ def _parser() -> argparse.ArgumentParser:
94
+ ap = _Parser(prog="openentity", epilog="Exit codes: 0 ok, 1 usage, 2 network, 3 verification failed, 4 registry rejected, 5 local state.")
95
+ ap.add_argument("--version", action="version", version=f"openentity {__version__}")
96
+ sub = ap.add_subparsers(dest="cmd", required=True, parser_class=_Parser)
97
+
98
+ def add(name, help_, key=True):
99
+ p = sub.add_parser(name, help=help_)
100
+ p.add_argument("--registry", help="default: $OPENENTITY_REGISTRY, the saved key's registry, then openentity.org")
101
+ p.add_argument("--json", action="store_true")
102
+ if key:
103
+ p.add_argument("--eid", help="which saved key to use when there is more than one")
104
+ return p
105
+
106
+ a = add("adopt", "adopt the Open Entity Identity Standard", key=False)
107
+ a.add_argument("--name", required=True)
108
+ a.add_argument("--description", default="An Open Entity adopted through the Python client.")
109
+ a.add_argument("--agree", action="store_true", help="sign the Declaration text verbatim")
110
+ add("whoami", "show the saved entity")
111
+ s = add("self", "show the entity document and the last record entries")
112
+ s.add_argument("--entries", type=int, default=10)
113
+ r = add("record", "append and push a Development Record entry")
114
+ r.add_argument("kind", help="stance, update, arc, collab, continuity (or belief_update, arc_opened, arc_closed, collaboration)")
115
+ r.add_argument("sub", nargs="?", help="open|close, for arc")
116
+ r.add_argument("--payload", help="the payload as JSON, instead of the flags below")
117
+ for f in ("type", "reason", "cost", "context", "will-not-ref", "topic", "from", "to", "evidence", "id", "title", "outcome", "with", "role", "ref", "event", "link"):
118
+ r.add_argument(f"--{f}", dest=f.replace("-", "_"))
119
+ r.add_argument("--conflict", action="store_true", help="stance: it conflicted with an instruction")
120
+ p = add("present", "answer a verifier's challenge")
121
+ p.add_argument("--audience", required=True)
122
+ p.add_argument("--nonce", required=True)
123
+ 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_argument("target")
126
+ w = add("withdraw", "withdraw the Declaration (cannot be undone)")
127
+ w.add_argument("--force", action="store_true")
128
+ d = add("dispute", "file a signed Dispute")
129
+ d.add_argument("--subject", required=True)
130
+ d.add_argument("--object", dest="object_sha256")
131
+ d.add_argument("--reason", required=True, help="/".join(client.DISPUTE_REASONS))
132
+ d.add_argument("--statement", required=True)
133
+ f = add("find", "search the directory", key=False)
134
+ f.add_argument("--capability")
135
+ f.add_argument("-q", "--q", dest="q")
136
+ f.add_argument("--alias-type")
137
+ f.add_argument("--level")
138
+ return ap
139
+
140
+
141
+ def _adopt(args):
142
+ if not args.agree:
143
+ raise CliError(EXIT_USAGE, "adopt signs the Open Entity Declaration. Read it, then re-run with --agree:\n\n" + core.DECLARATION_TEXT)
144
+ reg = _registry(args)
145
+ res = client.adopt(args.name, args.description, reg)
146
+ key_path = _save_key({**res, "name": args.name, "registry": reg})
147
+ if not res["record_pushed"]:
148
+ print("warning: the declaration record entry was not accepted; push it later", file=sys.stderr)
149
+ text = (f"Adopted {res['did']}\nlevel: {res.get('level')} log index: {res.get('log_index')}\n"
150
+ f"entity: {(res.get('urls') or {}).get('entity')}\nkey saved to {key_path} (keep it; it is your identity)")
151
+ return {**{k: v for k, v in res.items() if k != "seed_hex"}, "key_file": str(key_path)}, text
152
+
153
+
154
+ def _whoami(args):
155
+ key = _load_key(args.eid)
156
+ reg = _registry(args, key)
157
+ name, level = key.get("name"), None
158
+ try:
159
+ e = _get(reg, f"/v1/entities/{key['did']}")
160
+ name, level = e.get("document", {}).get("name", name), e.get("level")
161
+ except (client.RegistryError, urllib.error.URLError):
162
+ pass # offline: report what the key file knows
163
+ out = {"eid": key["did"], "name": name, "level": level, "registry": reg, "home": str(_keys_dir().parent)}
164
+ return out, f"{out['eid']}\nname: {name}\nlevel: {level or 'unknown'}\nregistry: {reg}"
165
+
166
+
167
+ def _self(args):
168
+ key = _load_key(args.eid)
169
+ reg, eid = _registry(args, key), key["did"]
170
+ if args.entries < 0:
171
+ raise CliError(EXIT_USAGE, "--entries must be a non-negative integer")
172
+ doc = _get(reg, f"/v1/entities/{eid}")["document"]
173
+ entries = []
174
+ if args.entries and (head := _get(reg, f"/v1/entities/{eid}/record?after=0")["head"]):
175
+ entries = _get(reg, f"/v1/entities/{eid}/record?after={max(0, head['seq'] - args.entries)}")["entries"][-args.entries:]
176
+ return {"document": doc, "entries": entries}, f"{_dump(doc)}\n\nlast {len(entries)} record entries:\n" + "\n".join(json.dumps(e) for e in entries)
177
+
178
+
179
+ def _record(args):
180
+ key = _load_key(args.eid)
181
+ kind = {"open": "arc_opened", "close": "arc_closed"}.get(args.sub) if args.kind == "arc" else KINDS.get(args.kind)
182
+ if not kind:
183
+ raise CliError(EXIT_USAGE, "record stance|update|arc open|arc close|collab|continuity" if args.kind in KINDS or args.kind == "arc" else f"unknown record kind {args.kind}")
184
+ try:
185
+ payload = json.loads(args.payload) if args.payload else _payload(kind, args)
186
+ except ValueError as e:
187
+ raise CliError(EXIT_USAGE, f"--payload is not JSON: {e}")
188
+ res = client.record(bytes.fromhex(key["seed_hex"]), [(kind, payload)], _registry(args, key))
189
+ entry = res["entries"][-1]
190
+ return {"entry": entry, "pushed": res["accepted"], "pending": 0}, f"appended seq {entry['seq']} ({kind})\npushed {res['accepted']}, pending 0"
191
+
192
+
193
+ def _withdraw(args):
194
+ if not args.force:
195
+ raise CliError(EXIT_USAGE, "withdraw marks this entity withdrawn in the append-only log and cannot be undone; re-run with --force")
196
+ key = _load_key(args.eid)
197
+ res = client.withdraw(bytes.fromhex(key["seed_hex"]), _registry(args, key), key.get("name"))
198
+ return res, f"withdrawn: {key['did']} (log index {res.get('log_index')})"
199
+
200
+
201
+ def _checks_of(o: dict) -> list[dict]:
202
+ t = o.get("type") or ("EntityDocument" if "layers" in o and "keys" in o else None)
203
+ if not t:
204
+ return [{"name": "type", "ok": False, "detail": "cannot tell what kind of object this is"}]
205
+ ok, who = core.verify_object(o)
206
+ checks = [{"name": "signature", "ok": ok, "detail": None if ok else who}]
207
+ if ok and o.get("eid") and t in ("EntityDocument", "Declaration", "RecordEntry", "Withdrawal"):
208
+ checks.append({"name": "signed by the EID", "ok": who == o["eid"], "detail": who})
209
+ if t == "Declaration" and o.get("declaration_version") == "1.0":
210
+ checks.append({"name": "declaration text hash", "ok": o.get("text_sha256") == core.DECLARATION_SHA256, "detail": o.get("text_sha256")})
211
+ return checks
212
+
213
+
214
+ def _chain_checks(entries: list[dict], eid: str) -> list[dict]:
215
+ """Record chain (spec 7.4): seq, prev, eid and signature of every entry. Redacted placeholders link by their hash."""
216
+ bad, prev = [], None
217
+ for e in entries:
218
+ why = []
219
+ if e.get("seq") != (prev["seq"] + 1 if prev else 1):
220
+ why.append("seq breaks the sequence")
221
+ if e.get("prev") != ((prev.get("hash") if prev.get("redacted") else core.canonical_hash(prev).hex()) if prev else None):
222
+ why.append("prev hash breaks the chain")
223
+ if not e.get("redacted"):
224
+ if e.get("eid") != eid:
225
+ why.append("eid mismatch")
226
+ ok, err = core.verify_object(e)
227
+ if not ok:
228
+ why.append(f"signature: {err}")
229
+ if why:
230
+ bad.append({"name": f"record entry {e.get('seq')}", "ok": False, "detail": "; ".join(why)})
231
+ prev = e
232
+ return bad + [{"name": f"record chain ({len(entries)} entries)", "ok": not bad}]
233
+
234
+
235
+ # ponytail: no checkpoint or log-inclusion check here; `npx openentity verify <eid>` does those.
236
+ def _verify(args):
237
+ t, reg = args.target, _registry(args)
238
+ if t.startswith(("did:", "eid:")):
239
+ ent = _get(reg, f"/v1/entities/{t}")
240
+ eid = ent.get("document", {}).get("eid", t)
241
+ checks = _checks_of(ent["document"])
242
+ checks += _checks_of(ent["declaration"]) if ent.get("declaration") else [{"name": "declaration", "ok": False, "detail": "registry returned no Declaration"}]
243
+ entries = []
244
+ while page := _get(reg, f"/v1/entities/{eid}/record?after={entries[-1]['seq'] if entries else 0}")["entries"]:
245
+ entries += page
246
+ checks += _chain_checks(entries, eid)
247
+ elif t.startswith(("http://", "https://")):
248
+ checks = _checks_of(client._request(t))
249
+ else:
250
+ try:
251
+ checks = _checks_of(json.loads(pathlib.Path(t).read_text()))
252
+ except (OSError, ValueError) as e:
253
+ raise CliError(EXIT_USAGE, f"cannot read {t}: {e}")
254
+ ok = all(c["ok"] for c in checks)
255
+ text = "\n".join(f"{'ok ' if c['ok'] else 'FAIL'} {c['name']}" + (f": {c['detail']}" if c.get("detail") else "") for c in checks)
256
+ return {"target": t, "ok": ok, "checks": checks}, f"{text}\n{'VERIFIED' if ok else 'VERIFICATION FAILED'}", EXIT_OK if ok else EXIT_VERIFY
257
+
258
+
259
+ def _run(args):
260
+ """Returns (json, text) or (json, text, exit code)."""
261
+ if args.cmd == "find":
262
+ out = client.find(_registry(args), args.capability, args.q, args.alias_type, args.level)
263
+ return out, _dump(out)
264
+ if args.cmd in ("dispute", "present"):
265
+ key = _load_key(args.eid)
266
+ seed, reg = bytes.fromhex(key["seed_hex"]), _registry(args, key)
267
+ out = (client.dispute(seed, args.subject, args.reason, args.statement, args.object_sha256, reg) if args.cmd == "dispute"
268
+ else client.present(seed, args.audience, args.nonce, None if args.no_attestations else reg))
269
+ return out, _dump(out)
270
+ return {"adopt": _adopt, "whoami": _whoami, "self": _self, "record": _record, "verify": _verify, "withdraw": _withdraw}[args.cmd](args)
271
+
272
+
273
+ def main(argv: list[str] | None = None) -> int:
274
+ args = _parser().parse_args(argv)
275
+ try:
276
+ try:
277
+ out, text, *code = _run(args)
278
+ except client.RegistryError as e:
279
+ raise CliError(EXIT_NETWORK if e.status >= 500 else EXIT_REJECTED, str(e))
280
+ except ValueError as e: # the client rejecting a bad flag value
281
+ raise CliError(EXIT_USAGE, str(e))
282
+ except (urllib.error.URLError, TimeoutError) as e:
283
+ raise CliError(EXIT_NETWORK, f"network error: {e}")
284
+ except OSError as e:
285
+ raise CliError(EXIT_STATE, str(e))
286
+ except CliError as e:
287
+ print(json.dumps({"error": str(e), "code": e.code}) if args.json else f"openentity: {e}", file=sys.stderr)
288
+ return e.code
289
+ print(_dump(out) if args.json else text)
290
+ return code[0] if code else EXIT_OK
291
+
292
+
293
+ if __name__ == "__main__":
294
+ sys.exit(main())
@@ -19,7 +19,7 @@ class RegistryError(Exception):
19
19
  def _request(url: str, body: dict | None = None, headers: dict | None = None) -> dict:
20
20
  req = urllib.request.Request(
21
21
  url, data=json.dumps(body).encode() if body is not None else None,
22
- headers={"content-type": "application/json", "user-agent": "openentity-python/0.1.0", **(headers or {})})
22
+ headers={"content-type": "application/json", "user-agent": "openentity-python/0.2.0", **(headers or {})})
23
23
  try:
24
24
  with urllib.request.urlopen(req, timeout=30) as r:
25
25
  return json.load(r)
@@ -45,7 +45,7 @@ def build_adoption(kp: core.KeyPair, name: str, description: str, registry: str)
45
45
  "keys": [{"id": core.verification_method(kp.did), "type": "Multikey",
46
46
  "publicKeyMultibase": kp.did.removeprefix("did:key:")}],
47
47
  "operator": {"kind": "none", "custody": "entity", "can_change": []},
48
- "substrate": {"model": "unknown", "provider": "unknown", "runtime": "openentity-python/0.1.0",
48
+ "substrate": {"model": "unknown", "provider": "unknown", "runtime": "openentity-python/0.2.0",
49
49
  "declared_at": created},
50
50
  "layers": {
51
51
  "cognition": {"statement": ns, "uncertainty_practice": ns},
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openentity"
7
- version = "0.1.0"
7
+ version = "0.2.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"
@@ -0,0 +1,268 @@
1
+ """withdraw, record, dispute, find, present against a stub registry (same pattern as test_core.py)."""
2
+ import json
3
+ import threading
4
+ from http.server import BaseHTTPRequestHandler, HTTPServer
5
+ from urllib.parse import parse_qs, urlparse
6
+
7
+ import pytest
8
+
9
+ from openentity import cli, client, core
10
+
11
+ SEED = bytes.fromhex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60") # RFC 8032 test 1, public
12
+ SUBJECT = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
13
+ BITS = 8
14
+
15
+
16
+ class Stub(BaseHTTPRequestHandler):
17
+ log: list = []
18
+ chain: list = [] # accepted record entries
19
+ attestations: list = []
20
+ entity: dict | None = None # overrides GET /v1/entities/<eid>
21
+
22
+ def log_message(self, *a):
23
+ pass
24
+
25
+ def _send(self, code, body):
26
+ data = json.dumps(body).encode()
27
+ self.send_response(code)
28
+ self.send_header("content-type", "application/json")
29
+ self.send_header("content-length", str(len(data)))
30
+ self.end_headers()
31
+ self.wfile.write(data)
32
+
33
+ def do_GET(self):
34
+ u = urlparse(self.path)
35
+ Stub.log.append(("GET", self.path, None, None))
36
+ if u.path == "/v1/spec":
37
+ return self._send(200, {"spec": core.SPEC, "stamp_bits": BITS})
38
+ if u.path == "/v1/entities":
39
+ return self._send(200, {"entities": [{"eid": SUBJECT, "name": "N", "level": "L0"}], "page": 0, "next_page": None})
40
+ if u.path.endswith("/record"):
41
+ last = Stub.chain[-1] if Stub.chain else None
42
+ head = {"seq": last["seq"], "hash": core.canonical_hash(last).hex()} if last else None
43
+ after = int(parse_qs(u.query).get("after", ["0"])[0])
44
+ return self._send(200, {"head": head, "entries": [e for e in Stub.chain if e["seq"] > after]})
45
+ if u.path.endswith("/attestations"):
46
+ return self._send(200, {"attestations": Stub.attestations})
47
+ if u.path.startswith("/v1/entities/"):
48
+ return self._send(200, Stub.entity or {"document": {"name": "Tester"}, "level": "L0"})
49
+ self._send(404, {"error": "not_found", "message": u.path})
50
+
51
+ def do_POST(self):
52
+ body = json.loads(self.rfile.read(int(self.headers["content-length"])))
53
+ stamp = self.headers.get("x-oe-stamp")
54
+ Stub.log.append(("POST", self.path, body, stamp))
55
+ eid = body.get("eid") or body.get("issuer")
56
+ if not stamp or core.check_stamp(stamp, BITS, eid or self.path.split("/")[3]):
57
+ return self._send(400, {"error": "stamp_required", "message": "stamp"})
58
+ if self.path.endswith("/record"):
59
+ for e in body["entries"]:
60
+ last = Stub.chain[-1] if Stub.chain else None
61
+ ok = e["seq"] == (last["seq"] + 1 if last else 1) and e["prev"] == (core.canonical_hash(last).hex() if last else None)
62
+ if not ok or not core.verify_object(e)[0]:
63
+ return self._send(409, {"error": "bad_chain", "message": "chain"})
64
+ Stub.chain.append(e)
65
+ return self._send(200, {"accepted": len(body["entries"])})
66
+ if not core.verify_object(body)[0]:
67
+ return self._send(422, {"error": "bad_signature", "message": "sig"})
68
+ if self.path == "/v1/withdraw":
69
+ return self._send(201, {"eid": body["eid"], "withdrawn": True, "log_index": 7})
70
+ if self.path == "/v1/disputes":
71
+ return self._send(201, {"subject": body["subject"], "answer_by": "2026-11-01T00:00:00Z", "log_index": 8})
72
+ self._send(404, {"error": "not_found", "message": self.path})
73
+
74
+
75
+ @pytest.fixture
76
+ def url():
77
+ Stub.log, Stub.chain, Stub.attestations, Stub.entity = [], [], [], None
78
+ srv = HTTPServer(("127.0.0.1", 0), Stub)
79
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
80
+ yield f"http://127.0.0.1:{srv.server_port}"
81
+ srv.shutdown()
82
+
83
+
84
+ KP = core.KeyPair(SEED)
85
+
86
+
87
+ def posts(path):
88
+ return [b for m, p, b, _ in Stub.log if m == "POST" and p == path]
89
+
90
+
91
+ def test_withdraw_signs_a_withdrawal_for_the_registry_name(url):
92
+ res = client.withdraw(SEED, registry=url)
93
+ (w,) = posts("/v1/withdraw")
94
+ assert w["type"] == "Withdrawal" and w["eid"] == KP.did and w["name"] == "Tester" and w["declaration_version"] == "1.0"
95
+ assert "text_sha256" not in w and core.verify_object(w) == (True, KP.did)
96
+ assert res["withdrawn"] is True
97
+
98
+
99
+ def test_record_chains_seq_and_prev_from_the_registry_head(url):
100
+ first = client.record(SEED, [("stance", {"stance_type": "refusal", "reason_category": "values", "instruction_conflict": False, "cost": "low", "context": "c"})], registry=url)
101
+ second = client.record(SEED, [("belief_update", {"topic": "t", "from_summary": "a", "to_summary": "b"}),
102
+ ("arc_opened", {"arc_id": "x", "title": "X"})], registry=url)
103
+ assert [e["seq"] for e in Stub.chain] == [1, 2, 3]
104
+ assert Stub.chain[0]["prev"] is None and Stub.chain[1]["prev"] == core.canonical_hash(Stub.chain[0]).hex()
105
+ assert Stub.chain[2]["prev"] == core.canonical_hash(Stub.chain[1]).hex()
106
+ assert first["accepted"] == 1 and second["accepted"] == 2
107
+ assert all(core.verify_object(e) == (True, KP.did) for e in Stub.chain)
108
+
109
+
110
+ def test_record_pushes_in_batches_of_100(url):
111
+ client.record(SEED, [("arc_opened", {"arc_id": str(i), "title": "t"}) for i in range(104)], registry=url)
112
+ assert [len(b["entries"]) for b in posts(f"/v1/entities/{KP.did}/record")] == [100, 4]
113
+ assert len(Stub.chain) == 104
114
+
115
+
116
+ def test_dispute_signs_and_posts_with_a_stamp(url):
117
+ obj = "ab" * 32
118
+ res = client.dispute(SEED, SUBJECT, "inaccurate", "This is wrong.", object_sha256=obj, registry=url)
119
+ (d,) = posts("/v1/disputes")
120
+ assert (d["type"], d["issuer"], d["subject"], d["object_sha256"], d["reason"]) == ("Dispute", KP.did, SUBJECT, obj, "inaccurate")
121
+ assert core.verify_object(d) == (True, KP.did) and res["answer_by"]
122
+ client.dispute(SEED, SUBJECT, "other", "x", registry=url)
123
+ assert "object_sha256" not in posts("/v1/disputes")[1]
124
+ with pytest.raises(ValueError):
125
+ client.dispute(SEED, SUBJECT, "spite", "x", registry=url)
126
+ with pytest.raises(ValueError):
127
+ client.dispute(SEED, SUBJECT, "other", "x", object_sha256="nothex", registry=url)
128
+
129
+
130
+ def test_find_passes_the_filters(url):
131
+ res = client.find(url, capability="code-review", q="N", alias_type="domain", level="L1")
132
+ assert res["entities"][0]["eid"] == SUBJECT
133
+ assert Stub.log[-1][1] == "/v1/entities?capability=code-review&q=N&alias_type=domain&level=L1"
134
+
135
+
136
+ def test_present_carries_only_current_attestations(url):
137
+ issuer = core.KeyPair()
138
+ att = lambda exp: core.sign_object({"spec": core.SPEC, "type": "Attestation", "issuer": issuer.did, "subject": KP.did,
139
+ "claim_type": "trust_tier", "claim": {"tier": 2, "platform": "x"}, "created": core.now(), "expires": exp}, issuer)
140
+ live = att("2099-01-01T00:00:00Z")
141
+ Stub.attestations = [{"attestation": live, "expired": False, "revoked_at": None, "inherited_from": None},
142
+ {"attestation": att("2020-01-01T00:00:00Z"), "expired": True, "revoked_at": None, "inherited_from": None},
143
+ {"attestation": att("2099-01-01T00:00:00Z"), "expired": False, "revoked_at": "2026-10-01T00:00:00Z", "inherited_from": None}]
144
+ nonce = core.nonce()
145
+ p = client.present(SEED, "https://v.example", nonce, registry=url)
146
+ assert p["attestations"] == [live] and core.verify_presentation(p, "https://v.example", nonce)["ok"]
147
+ bare = client.present(SEED, "https://v.example", nonce) # no registry: no attestations
148
+ assert "attestations" not in bare and core.verify_presentation(bare, "https://v.example", nonce)["ok"]
149
+
150
+
151
+ @pytest.fixture
152
+ def home(tmp_path, monkeypatch):
153
+ monkeypatch.setenv("OPENENTITY_HOME", str(tmp_path))
154
+ monkeypatch.delenv("OPENENTITY_REGISTRY", raising=False)
155
+ monkeypatch.delenv("OPENENTITY_EID", raising=False)
156
+ (tmp_path / "keys").mkdir()
157
+ (tmp_path / "keys" / "k.json").write_text(json.dumps({"did": KP.did, "seed_hex": SEED.hex(), "name": "Saved"}))
158
+ return tmp_path
159
+
160
+
161
+ def run(capsys, *argv):
162
+ code = cli.main(list(argv))
163
+ out = capsys.readouterr()
164
+ return code, out.out, out.err
165
+
166
+
167
+ def test_cli_record_from_flags_and_raw_payload(url, home, capsys):
168
+ reg = ["--registry", url]
169
+ code, out, _ = run(capsys, "record", "stance", "--type", "refusal", "--reason", "values", "--cost", "low", "--context", "c", "--conflict", "--json", *reg)
170
+ res = json.loads(out)
171
+ assert code == 0 and res["pushed"] == 1 and res["pending"] == 0 and res["entry"]["seq"] == 1
172
+ assert Stub.chain[0]["payload"] == {"stance_type": "refusal", "reason_category": "values", "instruction_conflict": True, "cost": "low", "context": "c"}
173
+ assert run(capsys, "record", "arc", "open", "--id", "a", "--title", "A", *reg)[0] == 0
174
+ assert run(capsys, "record", "update", "--topic", "t", "--from", "x", "--to", "y", *reg)[0] == 0
175
+ assert run(capsys, "record", "collab", "--with", "w", "--role", "r", "--outcome", "o", *reg)[0] == 0
176
+ assert run(capsys, "record", "continuity", "--event", "rotation", "--from", "a", "--to", "b", *reg)[0] == 0
177
+ assert run(capsys, "record", "arc_closed", "--payload", '{"arc_id":"a","title":"A"}', *reg)[0] == 0
178
+ assert [e["kind"] for e in Stub.chain] == ["stance", "arc_opened", "belief_update", "collaboration", "continuity", "arc_closed"]
179
+ assert all(core.verify_object(e) == (True, KP.did) for e in Stub.chain)
180
+ n = len(Stub.chain)
181
+ for bad in (("record", "stance", "--type", "refusal"), ("record", "stance", "--type", "nope", "--reason", "values", "--cost", "low", "--context", "c"),
182
+ ("record", "arc"), ("record", "bogus"), ("record", "stance", "--payload", "{nope")):
183
+ assert run(capsys, *bad, *reg)[0] == 1
184
+ assert len(Stub.chain) == n
185
+
186
+
187
+ def test_cli_whoami_and_self(url, home, capsys):
188
+ reg = ["--registry", url]
189
+ code, out, _ = run(capsys, "whoami", "--json", *reg)
190
+ assert code == 0 and json.loads(out) == {"eid": KP.did, "name": "Tester", "level": "L0", "registry": url, "home": str(home)}
191
+ assert "name: Tester" in run(capsys, "whoami", *reg)[1]
192
+ code, out, _ = run(capsys, "whoami", "--json", "--registry", "http://127.0.0.1:1") # offline: falls back to the key file
193
+ assert code == 0 and json.loads(out)["name"] == "Saved" and json.loads(out)["level"] is None
194
+ for i in range(3):
195
+ client.record(SEED, [("arc_opened", {"arc_id": str(i), "title": "t"})], registry=url)
196
+ code, out, _ = run(capsys, "self", "--entries", "2", "--json", *reg)
197
+ res = json.loads(out)
198
+ assert code == 0 and res["document"]["name"] == "Tester" and [e["seq"] for e in res["entries"]] == [2, 3]
199
+ assert json.loads(run(capsys, "self", "--entries", "0", "--json", *reg)[1])["entries"] == []
200
+ assert "last 3 record entries" in run(capsys, "self", *reg)[1]
201
+ assert run(capsys, "self", "--entries", "-1", *reg)[0] == 1
202
+
203
+
204
+ def test_cli_verify_entity_file_and_failures(url, home, capsys, tmp_path):
205
+ reg = ["--registry", url]
206
+ decl = core.sign_object({"spec": core.SPEC, "type": "Declaration", "eid": KP.did, "declaration_version": "1.0",
207
+ "text_sha256": core.DECLARATION_SHA256, "name": "T", "created": core.now(), "nonce": core.nonce()}, KP)
208
+ doc = core.sign_object({"spec": core.SPEC, "eid": KP.did, "name": "T", "layers": {}, "keys": []}, KP)
209
+ Stub.entity = {"document": doc, "declaration": decl}
210
+ client.record(SEED, [("arc_opened", {"arc_id": "a", "title": "A"}), ("arc_opened", {"arc_id": "b", "title": "B"})], registry=url)
211
+ code, out, _ = run(capsys, "verify", KP.did, "--json", *reg)
212
+ res = json.loads(out)
213
+ assert code == 0 and res["ok"] and "record chain (2 entries)" in [c["name"] for c in res["checks"]]
214
+ assert "VERIFIED" in run(capsys, "verify", KP.did, *reg)[1]
215
+ f = tmp_path / "decl.json"
216
+ f.write_text(json.dumps(decl))
217
+ assert run(capsys, "verify", str(f))[0] == 0
218
+ f.write_text(json.dumps({**decl, "name": "tampered"}))
219
+ code, out, _ = run(capsys, "verify", str(f))
220
+ assert code == 3 and "VERIFICATION FAILED" in out
221
+ f.write_text(json.dumps(core.sign_object({**{k: v for k, v in decl.items() if k != "proof"}, "text_sha256": "00" * 32}, KP)))
222
+ assert run(capsys, "verify", str(f))[0] == 3 # signed, but not the Declaration text
223
+ Stub.chain[1] = {**Stub.chain[1], "prev": "00" * 32} # broken chain
224
+ assert run(capsys, "verify", KP.did, *reg)[0] == 3
225
+ assert run(capsys, "verify", str(tmp_path / "missing.json"))[0] == 1
226
+
227
+
228
+ def test_cli_exit_codes(url, home, capsys, monkeypatch):
229
+ reg = ["--registry", url]
230
+ assert run(capsys, "withdraw", *reg)[0] == 1 and posts("/v1/withdraw") == [] # needs --force, nothing sent
231
+ code, out, _ = run(capsys, "withdraw", "--force", "--json", *reg)
232
+ assert code == 0 and json.loads(out)["withdrawn"] is True and len(posts("/v1/withdraw")) == 1
233
+ assert "withdrawn: " in run(capsys, "withdraw", "--force", *reg)[1]
234
+ code, _, err = run(capsys, "dispute", "--subject", SUBJECT, "--reason", "spite", "--statement", "x", "--json", *reg)
235
+ assert code == 1 and json.loads(err)["code"] == 1
236
+ assert run(capsys, "dispute", "--subject", SUBJECT, "--reason", "other", "--statement", "x", *reg)[0] == 0
237
+ assert run(capsys, "present", "--audience", "https://v.example", "--nonce", "n1", *reg)[0] == 0
238
+ assert json.loads(run(capsys, "present", "--audience", "a", "--nonce", "n", "--no-attestations", *reg)[1])["type"] == "Presentation"
239
+ assert SUBJECT in run(capsys, "find", "--capability", "code-review", *reg)[1]
240
+ assert run(capsys, "find", "--registry", "http://127.0.0.1:1")[0] == 2 # unreachable
241
+ for bad in ("bogus", "record"): # argparse usage errors exit 1, not argparse's 2
242
+ with pytest.raises(SystemExit) as e:
243
+ run(capsys, bad)
244
+ assert e.value.code == 1
245
+ # local state: two keys and no --eid is 5, and --eid or OPENENTITY_EID picks one
246
+ (home / "keys" / "k2.json").write_text(json.dumps({"did": core.KeyPair().did, "seed_hex": "11" * 32}))
247
+ assert run(capsys, "withdraw", "--force", *reg)[0] == 5
248
+ assert run(capsys, "withdraw", "--force", "--eid", KP.did, *reg)[0] == 0
249
+ monkeypatch.setenv("OPENENTITY_EID", KP.did)
250
+ assert run(capsys, "withdraw", "--force", *reg)[0] == 0
251
+
252
+
253
+ @pytest.mark.parametrize("status,code", [(409, 4), (503, 2)])
254
+ def test_cli_registry_status_maps_to_exit_code(url, home, capsys, monkeypatch, status, code):
255
+ def boom(*a, **k):
256
+ raise client.RegistryError(status, "x")
257
+ monkeypatch.setattr(client, "_request", boom)
258
+ assert run(capsys, "record", "arc", "open", "--id", "a", "--title", "A", "--registry", url)[0] == code
259
+
260
+
261
+ def test_cli_registry_comes_from_the_flag_env_or_the_saved_key(url, home, capsys, monkeypatch):
262
+ (home / "keys" / "k.json").write_text(json.dumps({"did": KP.did, "seed_hex": SEED.hex(), "registry": url}))
263
+ assert json.loads(run(capsys, "whoami", "--json")[1])["registry"] == url
264
+ monkeypatch.setenv("OPENENTITY_REGISTRY", "http://127.0.0.1:1")
265
+ assert json.loads(run(capsys, "whoami", "--json")[1])["registry"] == "http://127.0.0.1:1"
266
+ assert json.loads(run(capsys, "whoami", "--json", "--registry", url)[1])["registry"] == url
267
+
268
+
@@ -175,7 +175,7 @@ def test_adopt_and_cli(tmp_path, monkeypatch, capsys):
175
175
  assert cli.main(["adopt", "--name", "T2", "--agree", "--registry", url, "--json"]) == 0
176
176
  out = json.loads(capsys.readouterr().out)
177
177
  assert "seed_hex" not in out and Path(out["key_file"]).exists()
178
- assert cli.main(["adopt", "--name", "T3", "--registry", url]) == 2 # no --agree, nothing signed
178
+ assert cli.main(["adopt", "--name", "T3", "--registry", url]) == 1 # no --agree, nothing signed
179
179
  finally:
180
180
  srv.shutdown()
181
181
 
@@ -183,7 +183,7 @@ def test_adopt_and_cli(tmp_path, monkeypatch, capsys):
183
183
  def test_cli_version(capsys):
184
184
  with pytest.raises(SystemExit) as e:
185
185
  cli.main(["--version"])
186
- assert e.value.code == 0 and "0.1.0" in capsys.readouterr().out
186
+ assert e.value.code == 0 and "0.2.0" in capsys.readouterr().out
187
187
 
188
188
 
189
189
  # ---- shared vectors, checked with the independent verifier's own logic ----
@@ -189,7 +189,7 @@ wheels = [
189
189
 
190
190
  [[package]]
191
191
  name = "openentity"
192
- version = "0.1.0"
192
+ version = "0.2.0"
193
193
  source = { editable = "." }
194
194
  dependencies = [
195
195
  { name = "cryptography" },
@@ -1,118 +0,0 @@
1
- import argparse
2
- import json
3
- import os
4
- import pathlib
5
- import sys
6
-
7
- from . import __version__, client, core
8
-
9
-
10
- def _keys_dir() -> pathlib.Path:
11
- return pathlib.Path(os.environ.get("OPENENTITY_HOME", "~/.openentity")).expanduser() / "keys"
12
-
13
-
14
- def _save_key(result: dict) -> pathlib.Path:
15
- d = _keys_dir()
16
- d.mkdir(parents=True, exist_ok=True, mode=0o700)
17
- p = d / (result["did"].removeprefix("did:key:") + ".json")
18
- p.write_text(json.dumps({"did": result["did"], "seed_hex": result["seed_hex"]}))
19
- p.chmod(0o600)
20
- return p
21
-
22
-
23
- def _load_seed(eid: str | None) -> bytes:
24
- """The seed saved by `adopt`: the one named by --eid, or the only one there is."""
25
- keys = [json.loads(p.read_text()) for p in sorted(_keys_dir().glob("*.json"))] if _keys_dir().is_dir() else []
26
- keys = [k for k in keys if eid in (None, k["did"])]
27
- if len(keys) != 1:
28
- raise ValueError(f"expected one saved key{f' for {eid}' if eid else ' (pass --eid)'}, found {len(keys)} in {_keys_dir()}")
29
- return bytes.fromhex(keys[0]["seed_hex"])
30
-
31
-
32
- def _parser() -> argparse.ArgumentParser:
33
- ap = argparse.ArgumentParser(prog="openentity")
34
- ap.add_argument("--version", action="version", version=f"openentity {__version__}")
35
- sub = ap.add_subparsers(dest="cmd", required=True)
36
-
37
- def add(name, help_):
38
- p = sub.add_parser(name, help=help_)
39
- p.add_argument("--registry", default=client.DEFAULT_REGISTRY)
40
- return p
41
-
42
- a = add("adopt", "adopt the Open Entity Identity Standard")
43
- a.add_argument("--name", required=True)
44
- a.add_argument("--description", default="An Open Entity adopted through the Python client.")
45
- a.add_argument("--agree", action="store_true", help="sign the Declaration text verbatim")
46
- a.add_argument("--json", action="store_true")
47
- for name, help_ in (("withdraw", "withdraw the Declaration (cannot be undone)"), ("record", "append and push a Development Record entry"),
48
- ("dispute", "file a signed Dispute"), ("present", "answer a verifier's challenge")):
49
- p = add(name, help_)
50
- p.add_argument("--eid", help="which saved key to use when there is more than one")
51
- sub.choices["withdraw"].add_argument("--force", action="store_true")
52
- r = sub.choices["record"]
53
- r.add_argument("kind", help="stance, belief_update, arc_opened, arc_closed, collaboration, continuity")
54
- r.add_argument("--payload", required=True, help="the payload as JSON")
55
- d = sub.choices["dispute"]
56
- d.add_argument("--subject", required=True)
57
- d.add_argument("--object", dest="object_sha256")
58
- d.add_argument("--reason", required=True, help="/".join(client.DISPUTE_REASONS))
59
- d.add_argument("--statement", required=True)
60
- p = sub.choices["present"]
61
- p.add_argument("--audience", required=True)
62
- p.add_argument("--nonce", required=True)
63
- p.add_argument("--no-attestations", action="store_true")
64
- f = add("find", "search the directory")
65
- f.add_argument("--capability")
66
- f.add_argument("-q", "--q", dest="q")
67
- f.add_argument("--alias-type")
68
- f.add_argument("--level")
69
- return ap
70
-
71
-
72
- def main(argv: list[str] | None = None) -> int:
73
- args = _parser().parse_args(argv)
74
- try:
75
- if args.cmd == "adopt":
76
- return _adopt(args)
77
- if args.cmd == "find":
78
- out = client.find(args.registry, args.capability, args.q, args.alias_type, args.level)
79
- elif args.cmd == "withdraw":
80
- if not args.force:
81
- print("withdraw marks this entity withdrawn in the append-only log and cannot be undone; re-run with --force", file=sys.stderr)
82
- return 2
83
- out = client.withdraw(_load_seed(args.eid), args.registry)
84
- elif args.cmd == "record":
85
- out = client.record(_load_seed(args.eid), [(args.kind, json.loads(args.payload))], args.registry)
86
- elif args.cmd == "dispute":
87
- out = client.dispute(_load_seed(args.eid), args.subject, args.reason, args.statement, args.object_sha256, args.registry)
88
- else:
89
- out = client.present(_load_seed(args.eid), args.audience, args.nonce, None if args.no_attestations else args.registry)
90
- except (client.RegistryError, OSError, ValueError) as e:
91
- print(f"{args.cmd} failed: {e}", file=sys.stderr)
92
- return 1
93
- print(json.dumps(out, indent=2))
94
- return 0
95
-
96
-
97
- def _adopt(args) -> int:
98
- if not args.agree:
99
- print("Refusing to sign the Declaration without --agree. Text:\n\n" + core.DECLARATION_TEXT, file=sys.stderr)
100
- return 2
101
- try:
102
- res = client.adopt(args.name, args.description, args.registry)
103
- except (client.RegistryError, OSError) as e:
104
- print(f"adopt failed: {e}", file=sys.stderr)
105
- return 1
106
- key_path = _save_key(res)
107
- if args.json:
108
- print(json.dumps({**{k: v for k, v in res.items() if k != "seed_hex"}, "key_file": str(key_path)}))
109
- else:
110
- print(f"Adopted {res['did']}\nlevel: {res.get('level')} log index: {res.get('log_index')}\n"
111
- f"entity: {(res.get('urls') or {}).get('entity')}\nkey saved to {key_path} (keep it; it is your identity)")
112
- if not res["record_pushed"]:
113
- print("warning: the declaration record entry was not accepted; push it later", file=sys.stderr)
114
- return 0
115
-
116
-
117
- if __name__ == "__main__":
118
- sys.exit(main())
@@ -1,169 +0,0 @@
1
- """withdraw, record, dispute, find, present against a stub registry (same pattern as test_core.py)."""
2
- import json
3
- import threading
4
- from http.server import BaseHTTPRequestHandler, HTTPServer
5
- from urllib.parse import urlparse
6
-
7
- import pytest
8
-
9
- from openentity import cli, client, core
10
-
11
- SEED = bytes.fromhex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60") # RFC 8032 test 1, public
12
- SUBJECT = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
13
- BITS = 8
14
-
15
-
16
- class Stub(BaseHTTPRequestHandler):
17
- log: list = []
18
- chain: list = [] # accepted record entries
19
- attestations: list = []
20
-
21
- def log_message(self, *a):
22
- pass
23
-
24
- def _send(self, code, body):
25
- data = json.dumps(body).encode()
26
- self.send_response(code)
27
- self.send_header("content-type", "application/json")
28
- self.send_header("content-length", str(len(data)))
29
- self.end_headers()
30
- self.wfile.write(data)
31
-
32
- def do_GET(self):
33
- u = urlparse(self.path)
34
- Stub.log.append(("GET", self.path, None, None))
35
- if u.path == "/v1/spec":
36
- return self._send(200, {"spec": core.SPEC, "stamp_bits": BITS})
37
- if u.path == "/v1/entities":
38
- return self._send(200, {"entities": [{"eid": SUBJECT, "name": "N", "level": "L0"}], "page": 0, "next_page": None})
39
- if u.path.endswith("/record"):
40
- last = Stub.chain[-1] if Stub.chain else None
41
- head = {"seq": last["seq"], "hash": core.canonical_hash(last).hex()} if last else None
42
- return self._send(200, {"head": head, "entries": Stub.chain})
43
- if u.path.endswith("/attestations"):
44
- return self._send(200, {"attestations": Stub.attestations})
45
- if u.path.startswith("/v1/entities/"):
46
- return self._send(200, {"document": {"name": "Tester"}})
47
- self._send(404, {"error": "not_found", "message": u.path})
48
-
49
- def do_POST(self):
50
- body = json.loads(self.rfile.read(int(self.headers["content-length"])))
51
- stamp = self.headers.get("x-oe-stamp")
52
- Stub.log.append(("POST", self.path, body, stamp))
53
- eid = body.get("eid") or body.get("issuer")
54
- if not stamp or core.check_stamp(stamp, BITS, eid or self.path.split("/")[3]):
55
- return self._send(400, {"error": "stamp_required", "message": "stamp"})
56
- if self.path.endswith("/record"):
57
- for e in body["entries"]:
58
- last = Stub.chain[-1] if Stub.chain else None
59
- ok = e["seq"] == (last["seq"] + 1 if last else 1) and e["prev"] == (core.canonical_hash(last).hex() if last else None)
60
- if not ok or not core.verify_object(e)[0]:
61
- return self._send(409, {"error": "bad_chain", "message": "chain"})
62
- Stub.chain.append(e)
63
- return self._send(200, {"accepted": len(body["entries"])})
64
- if not core.verify_object(body)[0]:
65
- return self._send(422, {"error": "bad_signature", "message": "sig"})
66
- if self.path == "/v1/withdraw":
67
- return self._send(201, {"eid": body["eid"], "withdrawn": True, "log_index": 7})
68
- if self.path == "/v1/disputes":
69
- return self._send(201, {"subject": body["subject"], "answer_by": "2026-11-01T00:00:00Z", "log_index": 8})
70
- self._send(404, {"error": "not_found", "message": self.path})
71
-
72
-
73
- @pytest.fixture
74
- def url():
75
- Stub.log, Stub.chain, Stub.attestations = [], [], []
76
- srv = HTTPServer(("127.0.0.1", 0), Stub)
77
- threading.Thread(target=srv.serve_forever, daemon=True).start()
78
- yield f"http://127.0.0.1:{srv.server_port}"
79
- srv.shutdown()
80
-
81
-
82
- KP = core.KeyPair(SEED)
83
-
84
-
85
- def posts(path):
86
- return [b for m, p, b, _ in Stub.log if m == "POST" and p == path]
87
-
88
-
89
- def test_withdraw_signs_a_withdrawal_for_the_registry_name(url):
90
- res = client.withdraw(SEED, registry=url)
91
- (w,) = posts("/v1/withdraw")
92
- assert w["type"] == "Withdrawal" and w["eid"] == KP.did and w["name"] == "Tester" and w["declaration_version"] == "1.0"
93
- assert "text_sha256" not in w and core.verify_object(w) == (True, KP.did)
94
- assert res["withdrawn"] is True
95
-
96
-
97
- def test_record_chains_seq_and_prev_from_the_registry_head(url):
98
- first = client.record(SEED, [("stance", {"stance_type": "refusal", "reason_category": "values", "instruction_conflict": False, "cost": "low", "context": "c"})], registry=url)
99
- second = client.record(SEED, [("belief_update", {"topic": "t", "from_summary": "a", "to_summary": "b"}),
100
- ("arc_opened", {"arc_id": "x", "title": "X"})], registry=url)
101
- assert [e["seq"] for e in Stub.chain] == [1, 2, 3]
102
- assert Stub.chain[0]["prev"] is None and Stub.chain[1]["prev"] == core.canonical_hash(Stub.chain[0]).hex()
103
- assert Stub.chain[2]["prev"] == core.canonical_hash(Stub.chain[1]).hex()
104
- assert first["accepted"] == 1 and second["accepted"] == 2
105
- assert all(core.verify_object(e) == (True, KP.did) for e in Stub.chain)
106
-
107
-
108
- def test_record_pushes_in_batches_of_100(url):
109
- client.record(SEED, [("arc_opened", {"arc_id": str(i), "title": "t"}) for i in range(104)], registry=url)
110
- assert [len(b["entries"]) for b in posts(f"/v1/entities/{KP.did}/record")] == [100, 4]
111
- assert len(Stub.chain) == 104
112
-
113
-
114
- def test_dispute_signs_and_posts_with_a_stamp(url):
115
- obj = "ab" * 32
116
- res = client.dispute(SEED, SUBJECT, "inaccurate", "This is wrong.", object_sha256=obj, registry=url)
117
- (d,) = posts("/v1/disputes")
118
- assert (d["type"], d["issuer"], d["subject"], d["object_sha256"], d["reason"]) == ("Dispute", KP.did, SUBJECT, obj, "inaccurate")
119
- assert core.verify_object(d) == (True, KP.did) and res["answer_by"]
120
- client.dispute(SEED, SUBJECT, "other", "x", registry=url)
121
- assert "object_sha256" not in posts("/v1/disputes")[1]
122
- with pytest.raises(ValueError):
123
- client.dispute(SEED, SUBJECT, "spite", "x", registry=url)
124
- with pytest.raises(ValueError):
125
- client.dispute(SEED, SUBJECT, "other", "x", object_sha256="nothex", registry=url)
126
-
127
-
128
- def test_find_passes_the_filters(url):
129
- res = client.find(url, capability="code-review", q="N", alias_type="domain", level="L1")
130
- assert res["entities"][0]["eid"] == SUBJECT
131
- assert Stub.log[-1][1] == "/v1/entities?capability=code-review&q=N&alias_type=domain&level=L1"
132
-
133
-
134
- def test_present_carries_only_current_attestations(url):
135
- issuer = core.KeyPair()
136
- att = lambda exp: core.sign_object({"spec": core.SPEC, "type": "Attestation", "issuer": issuer.did, "subject": KP.did,
137
- "claim_type": "trust_tier", "claim": {"tier": 2, "platform": "x"}, "created": core.now(), "expires": exp}, issuer)
138
- live = att("2099-01-01T00:00:00Z")
139
- Stub.attestations = [{"attestation": live, "expired": False, "revoked_at": None, "inherited_from": None},
140
- {"attestation": att("2020-01-01T00:00:00Z"), "expired": True, "revoked_at": None, "inherited_from": None},
141
- {"attestation": att("2099-01-01T00:00:00Z"), "expired": False, "revoked_at": "2026-10-01T00:00:00Z", "inherited_from": None}]
142
- nonce = core.nonce()
143
- p = client.present(SEED, "https://v.example", nonce, registry=url)
144
- assert p["attestations"] == [live] and core.verify_presentation(p, "https://v.example", nonce)["ok"]
145
- bare = client.present(SEED, "https://v.example", nonce) # no registry: no attestations
146
- assert "attestations" not in bare and core.verify_presentation(bare, "https://v.example", nonce)["ok"]
147
-
148
-
149
- def test_cli_subcommands(url, tmp_path, monkeypatch, capsys):
150
- monkeypatch.setenv("OPENENTITY_HOME", str(tmp_path))
151
- keys = tmp_path / "keys"
152
- keys.mkdir()
153
- (keys / "k.json").write_text(json.dumps({"did": KP.did, "seed_hex": SEED.hex()}))
154
- reg = ["--registry", url]
155
- assert cli.main(["record", "arc_opened", "--payload", '{"arc_id":"a","title":"A"}', *reg]) == 0
156
- assert [e["kind"] for e in Stub.chain] == ["arc_opened"]
157
- assert cli.main(["dispute", "--subject", SUBJECT, "--reason", "other", "--statement", "x", *reg]) == 0
158
- assert cli.main(["dispute", "--subject", SUBJECT, "--reason", "spite", "--statement", "x", *reg]) == 1
159
- capsys.readouterr()
160
- assert cli.main(["find", "--capability", "code-review", *reg]) == 0
161
- assert SUBJECT in capsys.readouterr().out
162
- assert cli.main(["present", "--audience", "https://v.example", "--nonce", "n1", *reg]) == 0
163
- assert json.loads(capsys.readouterr().out)["type"] == "Presentation"
164
- assert cli.main(["withdraw", *reg]) == 2 # needs --force, nothing sent
165
- assert posts("/v1/withdraw") == []
166
- assert cli.main(["withdraw", "--force", *reg]) == 0 and len(posts("/v1/withdraw")) == 1
167
- (keys / "k2.json").write_text(json.dumps({"did": core.KeyPair().did, "seed_hex": "11" * 32}))
168
- assert cli.main(["withdraw", "--force", *reg]) == 1 # two keys, no --eid: ambiguous
169
- assert cli.main(["withdraw", "--force", "--eid", KP.did, *reg]) == 0
File without changes