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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: openentity
3
- Version: 0.2.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
@@ -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"
@@ -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.2.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"
@@ -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.2.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.2.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.2.0"
File without changes
File without changes
File without changes