openentity 0.2.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.
- {openentity-0.2.0 → openentity-0.3.0}/PKG-INFO +1 -1
- openentity-0.3.0/openentity/__init__.py +4 -0
- {openentity-0.2.0 → openentity-0.3.0}/openentity/client.py +12 -0
- {openentity-0.2.0 → openentity-0.3.0}/openentity/core.py +27 -3
- {openentity-0.2.0 → openentity-0.3.0}/pyproject.toml +1 -1
- {openentity-0.2.0 → openentity-0.3.0}/tests/test_client.py +29 -2
- {openentity-0.2.0 → openentity-0.3.0}/tests/test_core.py +18 -1
- {openentity-0.2.0 → openentity-0.3.0}/uv.lock +1 -1
- openentity-0.2.0/openentity/__init__.py +0 -4
- {openentity-0.2.0 → openentity-0.3.0}/.gitignore +0 -0
- {openentity-0.2.0 → openentity-0.3.0}/README.md +0 -0
- {openentity-0.2.0 → openentity-0.3.0}/openentity/cli.py +0 -0
- {openentity-0.2.0 → openentity-0.3.0}/tests/test_verifier.py +0 -0
|
@@ -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
|
|
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
|
|
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 {**
|
|
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) ----
|
|
@@ -18,6 +18,7 @@ class Stub(BaseHTTPRequestHandler):
|
|
|
18
18
|
chain: list = [] # accepted record entries
|
|
19
19
|
attestations: list = []
|
|
20
20
|
entity: dict | None = None # overrides GET /v1/entities/<eid>
|
|
21
|
+
refuse = False # refuse every continuity link
|
|
21
22
|
|
|
22
23
|
def log_message(self, *a):
|
|
23
24
|
pass
|
|
@@ -44,6 +45,8 @@ class Stub(BaseHTTPRequestHandler):
|
|
|
44
45
|
return self._send(200, {"head": head, "entries": [e for e in Stub.chain if e["seq"] > after]})
|
|
45
46
|
if u.path.endswith("/attestations"):
|
|
46
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])
|
|
47
50
|
if u.path.startswith("/v1/entities/"):
|
|
48
51
|
return self._send(200, Stub.entity or {"document": {"name": "Tester"}, "level": "L0"})
|
|
49
52
|
self._send(404, {"error": "not_found", "message": u.path})
|
|
@@ -52,7 +55,7 @@ class Stub(BaseHTTPRequestHandler):
|
|
|
52
55
|
body = json.loads(self.rfile.read(int(self.headers["content-length"])))
|
|
53
56
|
stamp = self.headers.get("x-oe-stamp")
|
|
54
57
|
Stub.log.append(("POST", self.path, body, stamp))
|
|
55
|
-
eid = body.get("eid") or body.get("issuer")
|
|
58
|
+
eid = body.get("eid") or body.get("issuer") or body.get("link", {}).get("successor")
|
|
56
59
|
if not stamp or core.check_stamp(stamp, BITS, eid or self.path.split("/")[3]):
|
|
57
60
|
return self._send(400, {"error": "stamp_required", "message": "stamp"})
|
|
58
61
|
if self.path.endswith("/record"):
|
|
@@ -63,6 +66,12 @@ class Stub(BaseHTTPRequestHandler):
|
|
|
63
66
|
return self._send(409, {"error": "bad_chain", "message": "chain"})
|
|
64
67
|
Stub.chain.append(e)
|
|
65
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"]})
|
|
66
75
|
if not core.verify_object(body)[0]:
|
|
67
76
|
return self._send(422, {"error": "bad_signature", "message": "sig"})
|
|
68
77
|
if self.path == "/v1/withdraw":
|
|
@@ -74,7 +83,7 @@ class Stub(BaseHTTPRequestHandler):
|
|
|
74
83
|
|
|
75
84
|
@pytest.fixture
|
|
76
85
|
def url():
|
|
77
|
-
Stub.log, Stub.chain, Stub.attestations, Stub.entity = [], [], [], None
|
|
86
|
+
Stub.log, Stub.chain, Stub.attestations, Stub.entity, Stub.refuse = [], [], [], None, False
|
|
78
87
|
srv = HTTPServer(("127.0.0.1", 0), Stub)
|
|
79
88
|
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
80
89
|
yield f"http://127.0.0.1:{srv.server_port}"
|
|
@@ -266,3 +275,21 @@ def test_cli_registry_comes_from_the_flag_env_or_the_saved_key(url, home, capsys
|
|
|
266
275
|
assert json.loads(run(capsys, "whoami", "--json", "--registry", url)[1])["registry"] == url
|
|
267
276
|
|
|
268
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
|
|
@@ -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.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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|