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.
- {openentity-0.1.0 → openentity-0.2.0}/PKG-INFO +14 -4
- {openentity-0.1.0 → openentity-0.2.0}/README.md +13 -3
- {openentity-0.1.0 → openentity-0.2.0}/openentity/__init__.py +1 -1
- openentity-0.2.0/openentity/cli.py +294 -0
- {openentity-0.1.0 → openentity-0.2.0}/openentity/client.py +2 -2
- {openentity-0.1.0 → openentity-0.2.0}/pyproject.toml +1 -1
- openentity-0.2.0/tests/test_client.py +268 -0
- {openentity-0.1.0 → openentity-0.2.0}/tests/test_core.py +2 -2
- {openentity-0.1.0 → openentity-0.2.0}/uv.lock +1 -1
- openentity-0.1.0/openentity/cli.py +0 -118
- openentity-0.1.0/tests/test_client.py +0 -169
- {openentity-0.1.0 → openentity-0.2.0}/.gitignore +0 -0
- {openentity-0.1.0 → openentity-0.2.0}/openentity/core.py +0 -0
- {openentity-0.1.0 → openentity-0.2.0}/tests/test_verifier.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: openentity
|
|
3
|
-
Version: 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
|
|
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
|
|
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
|
|
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
|
|
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,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.
|
|
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.
|
|
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},
|
|
@@ -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]) ==
|
|
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.
|
|
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 ----
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|