openentity 0.1.0__tar.gz → 0.3.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.3.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.
@@ -0,0 +1,4 @@
1
+ from .client import adopt, dispute, find, present, record, rotate, withdraw # noqa: F401
2
+ from .core import * # noqa: F401,F403
3
+
4
+ __version__ = "0.3.0"
@@ -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},
@@ -120,6 +120,18 @@ def record(seed: bytes, entries: list[tuple[str, dict]], registry: str = DEFAULT
120
120
  return {"accepted": len(signed), "entries": signed}
121
121
 
122
122
 
123
+ def rotate(seed: bytes, registry: str = DEFAULT_REGISTRY, new_seed: bytes | None = None, basis: str = "rotation") -> dict:
124
+ """Rotate to a new key in one call (spec 9.2). Fetches the current document, signs the link with both keys, stamps for
125
+ the successor and submits. Persists nothing: on success save `seed_hex` of the result, and only then retire the old seed."""
126
+ registry = registry.rstrip("/")
127
+ old, new = core.KeyPair(seed), core.KeyPair(new_seed)
128
+ prev_doc = _request(f"{registry}/v1/entities/{old.did}/document")
129
+ body = core.build_rotation(old, new, core.successor_document(prev_doc, new), basis)
130
+ bits = int(_request(f"{registry}/v1/spec")["stamp_bits"])
131
+ out = _request(f"{registry}/v1/continuity", body, {"x-oe-stamp": core.mint_stamp(bits, new.did)})
132
+ return {**out, "previous": old.did, "did": new.did, "seed_hex": new.seed.hex(), **body}
133
+
134
+
123
135
  DISPUTE_REASONS = ("impersonation", "illegal_content", "inaccurate", "other")
124
136
 
125
137
 
@@ -135,15 +135,39 @@ def nonce() -> str:
135
135
  # ---- eddsa-jcs-2022 (spec 4.1) ----
136
136
  def _signing_input(obj: dict, proof: dict) -> bytes:
137
137
  config = {k: v for k, v in proof.items() if k != "proofValue"}
138
- doc = {k: v for k, v in obj.items() if k != "proof"}
138
+ doc = {k: v for k, v in obj.items() if k not in ("proof", "proofs")} # a ContinuityLink carries proofs[]
139
139
  return canonical_hash(config) + canonical_hash(doc)
140
140
 
141
141
 
142
- def sign_object(obj: dict, kp: KeyPair, created: str | None = None) -> dict:
142
+ def sign_proof(obj: dict, kp: KeyPair, created: str | None = None) -> dict:
143
143
  proof = {"type": "DataIntegrityProof", "cryptosuite": "eddsa-jcs-2022", "created": created or now(),
144
144
  "verificationMethod": verification_method(kp.did), "proofPurpose": "assertionMethod"}
145
145
  sig = kp.private.sign(_signing_input(obj, proof))
146
- return {**{k: v for k, v in obj.items() if k != "proof"}, "proof": {**proof, "proofValue": "z" + b58encode(sig)}}
146
+ return {**proof, "proofValue": "z" + b58encode(sig)}
147
+
148
+
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
+
152
+
153
+ # ---- continuity (spec 9) ----
154
+ def successor_document(prev: dict, nxt: KeyPair, created: str | None = None) -> dict:
155
+ """The successor's unsigned seq 1 document: `prev` re-keyed, aliases dropped (their proofs name the old EID)."""
156
+ created = created or now()
157
+ d = {k: v for k, v in prev.items() if k not in ("proof", "aliases")}
158
+ return {**d, "eid": nxt.did, "created": created, "updated": created, "seq": 1,
159
+ "keys": [{"id": verification_method(nxt.did), "type": "Multikey", "publicKeyMultibase": nxt.did.removeprefix("did:key:")}],
160
+ "continuity": {**d.get("continuity", {}), "previous": prev["eid"]}}
161
+
162
+
163
+ def build_rotation(previous: KeyPair, successor: KeyPair, document: dict, basis: str = "rotation",
164
+ created: str | None = None, nonce_: str | None = None) -> dict:
165
+ """The body of POST /v1/continuity: a link signed by both keys, and the successor's document signed by it."""
166
+ created = created or now()
167
+ body = {"spec": SPEC, "type": "ContinuityLink", "previous": previous.did, "successor": successor.did,
168
+ "basis": basis, "created": created, "nonce": nonce_ or nonce()}
169
+ link = {**body, "proofs": [sign_proof(body, previous, created), sign_proof(body, successor, created)]}
170
+ return {"link": link, "document": sign_object(document, successor, created)}
147
171
 
148
172
 
149
173
  # ---- EIP-712 for did:pkh (spec 4.2) ----
@@ -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.3.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,295 @@
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
+ refuse = False # refuse every continuity link
22
+
23
+ def log_message(self, *a):
24
+ pass
25
+
26
+ def _send(self, code, body):
27
+ data = json.dumps(body).encode()
28
+ self.send_response(code)
29
+ self.send_header("content-type", "application/json")
30
+ self.send_header("content-length", str(len(data)))
31
+ self.end_headers()
32
+ self.wfile.write(data)
33
+
34
+ def do_GET(self):
35
+ u = urlparse(self.path)
36
+ Stub.log.append(("GET", self.path, None, None))
37
+ if u.path == "/v1/spec":
38
+ return self._send(200, {"spec": core.SPEC, "stamp_bits": BITS})
39
+ if u.path == "/v1/entities":
40
+ return self._send(200, {"entities": [{"eid": SUBJECT, "name": "N", "level": "L0"}], "page": 0, "next_page": None})
41
+ if u.path.endswith("/record"):
42
+ last = Stub.chain[-1] if Stub.chain else None
43
+ head = {"seq": last["seq"], "hash": core.canonical_hash(last).hex()} if last else None
44
+ after = int(parse_qs(u.query).get("after", ["0"])[0])
45
+ return self._send(200, {"head": head, "entries": [e for e in Stub.chain if e["seq"] > after]})
46
+ if u.path.endswith("/attestations"):
47
+ return self._send(200, {"attestations": Stub.attestations})
48
+ if u.path.endswith("/document"):
49
+ return self._send(200, client.build_adoption(core.KeyPair(SEED), "Tester", "d", "http://x")[0])
50
+ if u.path.startswith("/v1/entities/"):
51
+ return self._send(200, Stub.entity or {"document": {"name": "Tester"}, "level": "L0"})
52
+ self._send(404, {"error": "not_found", "message": u.path})
53
+
54
+ def do_POST(self):
55
+ body = json.loads(self.rfile.read(int(self.headers["content-length"])))
56
+ stamp = self.headers.get("x-oe-stamp")
57
+ Stub.log.append(("POST", self.path, body, stamp))
58
+ eid = body.get("eid") or body.get("issuer") or body.get("link", {}).get("successor")
59
+ if not stamp or core.check_stamp(stamp, BITS, eid or self.path.split("/")[3]):
60
+ return self._send(400, {"error": "stamp_required", "message": "stamp"})
61
+ if self.path.endswith("/record"):
62
+ for e in body["entries"]:
63
+ last = Stub.chain[-1] if Stub.chain else None
64
+ ok = e["seq"] == (last["seq"] + 1 if last else 1) and e["prev"] == (core.canonical_hash(last).hex() if last else None)
65
+ if not ok or not core.verify_object(e)[0]:
66
+ return self._send(409, {"error": "bad_chain", "message": "chain"})
67
+ Stub.chain.append(e)
68
+ return self._send(200, {"accepted": len(body["entries"])})
69
+ if self.path == "/v1/continuity":
70
+ link = body["link"]
71
+ ok = not Stub.refuse and all(core.verify_object({**link, "proof": p}) == (True, d) for p, d in zip(link["proofs"], (link["previous"], link["successor"])))
72
+ if not ok or core.verify_object(body["document"])[1] != link["successor"]:
73
+ return self._send(422, {"error": "continuity_proof_missing", "message": "no"})
74
+ return self._send(201, {"previous": link["previous"], "successor": link["successor"], "basis": link["basis"]})
75
+ if not core.verify_object(body)[0]:
76
+ return self._send(422, {"error": "bad_signature", "message": "sig"})
77
+ if self.path == "/v1/withdraw":
78
+ return self._send(201, {"eid": body["eid"], "withdrawn": True, "log_index": 7})
79
+ if self.path == "/v1/disputes":
80
+ return self._send(201, {"subject": body["subject"], "answer_by": "2026-11-01T00:00:00Z", "log_index": 8})
81
+ self._send(404, {"error": "not_found", "message": self.path})
82
+
83
+
84
+ @pytest.fixture
85
+ def url():
86
+ Stub.log, Stub.chain, Stub.attestations, Stub.entity, Stub.refuse = [], [], [], None, False
87
+ srv = HTTPServer(("127.0.0.1", 0), Stub)
88
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
89
+ yield f"http://127.0.0.1:{srv.server_port}"
90
+ srv.shutdown()
91
+
92
+
93
+ KP = core.KeyPair(SEED)
94
+
95
+
96
+ def posts(path):
97
+ return [b for m, p, b, _ in Stub.log if m == "POST" and p == path]
98
+
99
+
100
+ def test_withdraw_signs_a_withdrawal_for_the_registry_name(url):
101
+ res = client.withdraw(SEED, registry=url)
102
+ (w,) = posts("/v1/withdraw")
103
+ assert w["type"] == "Withdrawal" and w["eid"] == KP.did and w["name"] == "Tester" and w["declaration_version"] == "1.0"
104
+ assert "text_sha256" not in w and core.verify_object(w) == (True, KP.did)
105
+ assert res["withdrawn"] is True
106
+
107
+
108
+ def test_record_chains_seq_and_prev_from_the_registry_head(url):
109
+ first = client.record(SEED, [("stance", {"stance_type": "refusal", "reason_category": "values", "instruction_conflict": False, "cost": "low", "context": "c"})], registry=url)
110
+ second = client.record(SEED, [("belief_update", {"topic": "t", "from_summary": "a", "to_summary": "b"}),
111
+ ("arc_opened", {"arc_id": "x", "title": "X"})], registry=url)
112
+ assert [e["seq"] for e in Stub.chain] == [1, 2, 3]
113
+ assert Stub.chain[0]["prev"] is None and Stub.chain[1]["prev"] == core.canonical_hash(Stub.chain[0]).hex()
114
+ assert Stub.chain[2]["prev"] == core.canonical_hash(Stub.chain[1]).hex()
115
+ assert first["accepted"] == 1 and second["accepted"] == 2
116
+ assert all(core.verify_object(e) == (True, KP.did) for e in Stub.chain)
117
+
118
+
119
+ def test_record_pushes_in_batches_of_100(url):
120
+ client.record(SEED, [("arc_opened", {"arc_id": str(i), "title": "t"}) for i in range(104)], registry=url)
121
+ assert [len(b["entries"]) for b in posts(f"/v1/entities/{KP.did}/record")] == [100, 4]
122
+ assert len(Stub.chain) == 104
123
+
124
+
125
+ def test_dispute_signs_and_posts_with_a_stamp(url):
126
+ obj = "ab" * 32
127
+ res = client.dispute(SEED, SUBJECT, "inaccurate", "This is wrong.", object_sha256=obj, registry=url)
128
+ (d,) = posts("/v1/disputes")
129
+ assert (d["type"], d["issuer"], d["subject"], d["object_sha256"], d["reason"]) == ("Dispute", KP.did, SUBJECT, obj, "inaccurate")
130
+ assert core.verify_object(d) == (True, KP.did) and res["answer_by"]
131
+ client.dispute(SEED, SUBJECT, "other", "x", registry=url)
132
+ assert "object_sha256" not in posts("/v1/disputes")[1]
133
+ with pytest.raises(ValueError):
134
+ client.dispute(SEED, SUBJECT, "spite", "x", registry=url)
135
+ with pytest.raises(ValueError):
136
+ client.dispute(SEED, SUBJECT, "other", "x", object_sha256="nothex", registry=url)
137
+
138
+
139
+ def test_find_passes_the_filters(url):
140
+ res = client.find(url, capability="code-review", q="N", alias_type="domain", level="L1")
141
+ assert res["entities"][0]["eid"] == SUBJECT
142
+ assert Stub.log[-1][1] == "/v1/entities?capability=code-review&q=N&alias_type=domain&level=L1"
143
+
144
+
145
+ def test_present_carries_only_current_attestations(url):
146
+ issuer = core.KeyPair()
147
+ att = lambda exp: core.sign_object({"spec": core.SPEC, "type": "Attestation", "issuer": issuer.did, "subject": KP.did,
148
+ "claim_type": "trust_tier", "claim": {"tier": 2, "platform": "x"}, "created": core.now(), "expires": exp}, issuer)
149
+ live = att("2099-01-01T00:00:00Z")
150
+ Stub.attestations = [{"attestation": live, "expired": False, "revoked_at": None, "inherited_from": None},
151
+ {"attestation": att("2020-01-01T00:00:00Z"), "expired": True, "revoked_at": None, "inherited_from": None},
152
+ {"attestation": att("2099-01-01T00:00:00Z"), "expired": False, "revoked_at": "2026-10-01T00:00:00Z", "inherited_from": None}]
153
+ nonce = core.nonce()
154
+ p = client.present(SEED, "https://v.example", nonce, registry=url)
155
+ assert p["attestations"] == [live] and core.verify_presentation(p, "https://v.example", nonce)["ok"]
156
+ bare = client.present(SEED, "https://v.example", nonce) # no registry: no attestations
157
+ assert "attestations" not in bare and core.verify_presentation(bare, "https://v.example", nonce)["ok"]
158
+
159
+
160
+ @pytest.fixture
161
+ def home(tmp_path, monkeypatch):
162
+ monkeypatch.setenv("OPENENTITY_HOME", str(tmp_path))
163
+ monkeypatch.delenv("OPENENTITY_REGISTRY", raising=False)
164
+ monkeypatch.delenv("OPENENTITY_EID", raising=False)
165
+ (tmp_path / "keys").mkdir()
166
+ (tmp_path / "keys" / "k.json").write_text(json.dumps({"did": KP.did, "seed_hex": SEED.hex(), "name": "Saved"}))
167
+ return tmp_path
168
+
169
+
170
+ def run(capsys, *argv):
171
+ code = cli.main(list(argv))
172
+ out = capsys.readouterr()
173
+ return code, out.out, out.err
174
+
175
+
176
+ def test_cli_record_from_flags_and_raw_payload(url, home, capsys):
177
+ reg = ["--registry", url]
178
+ code, out, _ = run(capsys, "record", "stance", "--type", "refusal", "--reason", "values", "--cost", "low", "--context", "c", "--conflict", "--json", *reg)
179
+ res = json.loads(out)
180
+ assert code == 0 and res["pushed"] == 1 and res["pending"] == 0 and res["entry"]["seq"] == 1
181
+ assert Stub.chain[0]["payload"] == {"stance_type": "refusal", "reason_category": "values", "instruction_conflict": True, "cost": "low", "context": "c"}
182
+ assert run(capsys, "record", "arc", "open", "--id", "a", "--title", "A", *reg)[0] == 0
183
+ assert run(capsys, "record", "update", "--topic", "t", "--from", "x", "--to", "y", *reg)[0] == 0
184
+ assert run(capsys, "record", "collab", "--with", "w", "--role", "r", "--outcome", "o", *reg)[0] == 0
185
+ assert run(capsys, "record", "continuity", "--event", "rotation", "--from", "a", "--to", "b", *reg)[0] == 0
186
+ assert run(capsys, "record", "arc_closed", "--payload", '{"arc_id":"a","title":"A"}', *reg)[0] == 0
187
+ assert [e["kind"] for e in Stub.chain] == ["stance", "arc_opened", "belief_update", "collaboration", "continuity", "arc_closed"]
188
+ assert all(core.verify_object(e) == (True, KP.did) for e in Stub.chain)
189
+ n = len(Stub.chain)
190
+ for bad in (("record", "stance", "--type", "refusal"), ("record", "stance", "--type", "nope", "--reason", "values", "--cost", "low", "--context", "c"),
191
+ ("record", "arc"), ("record", "bogus"), ("record", "stance", "--payload", "{nope")):
192
+ assert run(capsys, *bad, *reg)[0] == 1
193
+ assert len(Stub.chain) == n
194
+
195
+
196
+ def test_cli_whoami_and_self(url, home, capsys):
197
+ reg = ["--registry", url]
198
+ code, out, _ = run(capsys, "whoami", "--json", *reg)
199
+ assert code == 0 and json.loads(out) == {"eid": KP.did, "name": "Tester", "level": "L0", "registry": url, "home": str(home)}
200
+ assert "name: Tester" in run(capsys, "whoami", *reg)[1]
201
+ code, out, _ = run(capsys, "whoami", "--json", "--registry", "http://127.0.0.1:1") # offline: falls back to the key file
202
+ assert code == 0 and json.loads(out)["name"] == "Saved" and json.loads(out)["level"] is None
203
+ for i in range(3):
204
+ client.record(SEED, [("arc_opened", {"arc_id": str(i), "title": "t"})], registry=url)
205
+ code, out, _ = run(capsys, "self", "--entries", "2", "--json", *reg)
206
+ res = json.loads(out)
207
+ assert code == 0 and res["document"]["name"] == "Tester" and [e["seq"] for e in res["entries"]] == [2, 3]
208
+ assert json.loads(run(capsys, "self", "--entries", "0", "--json", *reg)[1])["entries"] == []
209
+ assert "last 3 record entries" in run(capsys, "self", *reg)[1]
210
+ assert run(capsys, "self", "--entries", "-1", *reg)[0] == 1
211
+
212
+
213
+ def test_cli_verify_entity_file_and_failures(url, home, capsys, tmp_path):
214
+ reg = ["--registry", url]
215
+ decl = core.sign_object({"spec": core.SPEC, "type": "Declaration", "eid": KP.did, "declaration_version": "1.0",
216
+ "text_sha256": core.DECLARATION_SHA256, "name": "T", "created": core.now(), "nonce": core.nonce()}, KP)
217
+ doc = core.sign_object({"spec": core.SPEC, "eid": KP.did, "name": "T", "layers": {}, "keys": []}, KP)
218
+ Stub.entity = {"document": doc, "declaration": decl}
219
+ client.record(SEED, [("arc_opened", {"arc_id": "a", "title": "A"}), ("arc_opened", {"arc_id": "b", "title": "B"})], registry=url)
220
+ code, out, _ = run(capsys, "verify", KP.did, "--json", *reg)
221
+ res = json.loads(out)
222
+ assert code == 0 and res["ok"] and "record chain (2 entries)" in [c["name"] for c in res["checks"]]
223
+ assert "VERIFIED" in run(capsys, "verify", KP.did, *reg)[1]
224
+ f = tmp_path / "decl.json"
225
+ f.write_text(json.dumps(decl))
226
+ assert run(capsys, "verify", str(f))[0] == 0
227
+ f.write_text(json.dumps({**decl, "name": "tampered"}))
228
+ code, out, _ = run(capsys, "verify", str(f))
229
+ assert code == 3 and "VERIFICATION FAILED" in out
230
+ f.write_text(json.dumps(core.sign_object({**{k: v for k, v in decl.items() if k != "proof"}, "text_sha256": "00" * 32}, KP)))
231
+ assert run(capsys, "verify", str(f))[0] == 3 # signed, but not the Declaration text
232
+ Stub.chain[1] = {**Stub.chain[1], "prev": "00" * 32} # broken chain
233
+ assert run(capsys, "verify", KP.did, *reg)[0] == 3
234
+ assert run(capsys, "verify", str(tmp_path / "missing.json"))[0] == 1
235
+
236
+
237
+ def test_cli_exit_codes(url, home, capsys, monkeypatch):
238
+ reg = ["--registry", url]
239
+ assert run(capsys, "withdraw", *reg)[0] == 1 and posts("/v1/withdraw") == [] # needs --force, nothing sent
240
+ code, out, _ = run(capsys, "withdraw", "--force", "--json", *reg)
241
+ assert code == 0 and json.loads(out)["withdrawn"] is True and len(posts("/v1/withdraw")) == 1
242
+ assert "withdrawn: " in run(capsys, "withdraw", "--force", *reg)[1]
243
+ code, _, err = run(capsys, "dispute", "--subject", SUBJECT, "--reason", "spite", "--statement", "x", "--json", *reg)
244
+ assert code == 1 and json.loads(err)["code"] == 1
245
+ assert run(capsys, "dispute", "--subject", SUBJECT, "--reason", "other", "--statement", "x", *reg)[0] == 0
246
+ assert run(capsys, "present", "--audience", "https://v.example", "--nonce", "n1", *reg)[0] == 0
247
+ assert json.loads(run(capsys, "present", "--audience", "a", "--nonce", "n", "--no-attestations", *reg)[1])["type"] == "Presentation"
248
+ assert SUBJECT in run(capsys, "find", "--capability", "code-review", *reg)[1]
249
+ assert run(capsys, "find", "--registry", "http://127.0.0.1:1")[0] == 2 # unreachable
250
+ for bad in ("bogus", "record"): # argparse usage errors exit 1, not argparse's 2
251
+ with pytest.raises(SystemExit) as e:
252
+ run(capsys, bad)
253
+ assert e.value.code == 1
254
+ # local state: two keys and no --eid is 5, and --eid or OPENENTITY_EID picks one
255
+ (home / "keys" / "k2.json").write_text(json.dumps({"did": core.KeyPair().did, "seed_hex": "11" * 32}))
256
+ assert run(capsys, "withdraw", "--force", *reg)[0] == 5
257
+ assert run(capsys, "withdraw", "--force", "--eid", KP.did, *reg)[0] == 0
258
+ monkeypatch.setenv("OPENENTITY_EID", KP.did)
259
+ assert run(capsys, "withdraw", "--force", *reg)[0] == 0
260
+
261
+
262
+ @pytest.mark.parametrize("status,code", [(409, 4), (503, 2)])
263
+ def test_cli_registry_status_maps_to_exit_code(url, home, capsys, monkeypatch, status, code):
264
+ def boom(*a, **k):
265
+ raise client.RegistryError(status, "x")
266
+ monkeypatch.setattr(client, "_request", boom)
267
+ assert run(capsys, "record", "arc", "open", "--id", "a", "--title", "A", "--registry", url)[0] == code
268
+
269
+
270
+ def test_cli_registry_comes_from_the_flag_env_or_the_saved_key(url, home, capsys, monkeypatch):
271
+ (home / "keys" / "k.json").write_text(json.dumps({"did": KP.did, "seed_hex": SEED.hex(), "registry": url}))
272
+ assert json.loads(run(capsys, "whoami", "--json")[1])["registry"] == url
273
+ monkeypatch.setenv("OPENENTITY_REGISTRY", "http://127.0.0.1:1")
274
+ assert json.loads(run(capsys, "whoami", "--json")[1])["registry"] == "http://127.0.0.1:1"
275
+ assert json.loads(run(capsys, "whoami", "--json", "--registry", url)[1])["registry"] == url
276
+
277
+
278
+
279
+
280
+ def test_rotate_signs_with_both_keys_stamps_for_the_successor_and_returns_the_new_seed(url):
281
+ out = client.rotate(SEED, registry=url)
282
+ (body,) = posts("/v1/continuity")
283
+ new = core.KeyPair(bytes.fromhex(out["seed_hex"]))
284
+ link = body["link"]
285
+ assert (link["previous"], link["successor"], link["basis"]) == (KP.did, new.did, "rotation") and out["did"] == new.did
286
+ assert [p["verificationMethod"].split("#")[0] for p in link["proofs"]] == [KP.did, new.did]
287
+ assert body["document"]["eid"] == new.did and body["document"]["seq"] == 1 and body["document"]["continuity"]["previous"] == KP.did
288
+ assert core.check_stamp([s for m, p, _, s in Stub.log if p == "/v1/continuity"][0], BITS, new.did) is None
289
+
290
+
291
+ def test_rotate_the_registry_refuses_raises_and_returns_nothing(url):
292
+ Stub.refuse = True
293
+ with pytest.raises(client.RegistryError) as e:
294
+ client.rotate(SEED, registry=url)
295
+ assert e.value.status == 422
@@ -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.3.0" in capsys.readouterr().out
187
187
 
188
188
 
189
189
  # ---- shared vectors, checked with the independent verifier's own logic ----
@@ -241,3 +241,20 @@ def test_did_web_needs_a_resolver_and_uses_it():
241
241
  obj["proof"]["proofValue"] = "z" + core.b58encode(kp.private.sign(core._signing_input(obj, proof)))
242
242
  assert core.verify_object(obj)[0] is False
243
243
  assert core.verify_object(obj, resolver=lambda did: {"ed25519": [kp.public]}) == (True, web)
244
+
245
+
246
+ # ---- rotation vector: Python reproduces the TS-generated bytes (so each verifies the other's links) ----
247
+ def test_rotation_vector_is_reproduced_byte_for_byte():
248
+ vec = ROOT / "test-vectors"
249
+ if not vec.is_dir():
250
+ pytest.skip("test-vectors/ not present")
251
+ keys = json.loads((vec / "keys.json").read_text())
252
+ want = json.loads((vec / "rotation.json").read_text())["request"]
253
+ prev_doc = json.loads((vec / "entity-document.json").read_text())["object"]
254
+ a, b = core.KeyPair(bytes.fromhex(keys["entity"]["seed"])), core.KeyPair(bytes.fromhex(keys["second"]["seed"]))
255
+ created = want["link"]["created"]
256
+ got = core.build_rotation(a, b, core.successor_document(prev_doc, b, created), created=created, nonce_=want["link"]["nonce"])
257
+ assert core.canonicalize(got) == core.canonicalize(want)
258
+ link = want["link"]
259
+ assert [core.verify_object({**link, "proof": p})[1] for p in link["proofs"]] == [a.did, b.did]
260
+ assert core.verify_object(want["document"]) == (True, b.did)
@@ -189,7 +189,7 @@ wheels = [
189
189
 
190
190
  [[package]]
191
191
  name = "openentity"
192
- version = "0.1.0"
192
+ version = "0.3.0"
193
193
  source = { editable = "." }
194
194
  dependencies = [
195
195
  { name = "cryptography" },
@@ -1,4 +0,0 @@
1
- from .client import adopt, dispute, find, present, record, withdraw # noqa: F401
2
- from .core import * # noqa: F401,F403
3
-
4
- __version__ = "0.1.0"
@@ -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