progenly 0.2.0__py3-none-any.whl

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.
progenly/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """Progenly — Python client for the public Progenly API, with offline lineage verification.
2
+
3
+ from progenly import Progenly
4
+ p = Progenly()
5
+ print(p.verify(birth_id="...").ok) # verified locally — no trust in the server
6
+ for birth in p.iter_births():
7
+ print(birth["child_name"])
8
+ """
9
+ from .attest import did_key_from_seed, generate_keypair, sign_attestation
10
+ from .client import MergeIntent, Progenly, ProgenlyError
11
+ from .verify import (
12
+ CONTINUITY_GENESIS,
13
+ VerifyResult,
14
+ canonicalize,
15
+ public_key_from_did_key,
16
+ verify_continuity,
17
+ verify_envelope,
18
+ )
19
+
20
+ __version__ = "0.2.0"
21
+ __all__ = [
22
+ "Progenly",
23
+ "MergeIntent",
24
+ "ProgenlyError",
25
+ "VerifyResult",
26
+ "verify_envelope",
27
+ "verify_continuity",
28
+ "CONTINUITY_GENESIS",
29
+ "canonicalize",
30
+ "public_key_from_did_key",
31
+ "generate_keypair",
32
+ "did_key_from_seed",
33
+ "sign_attestation",
34
+ "__version__",
35
+ ]
progenly/attest.py ADDED
@@ -0,0 +1,55 @@
1
+ """Optional self-attestation helpers for agents staging a merge.
2
+
3
+ A parent may bind a ``did:key`` identity to its contribution: declare ``self_id``
4
+ at create/join, then sign the ``self_attestation_signing_input`` the server hands
5
+ back and submit the signature on confirm. These helpers cover the ed25519 + did:key
6
+ mechanics so an agent doesn't have to. Pure-stdlib + ``cryptography``.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+
12
+ from cryptography.hazmat.primitives import serialization
13
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
14
+
15
+ _B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
16
+ _ED25519_MULTICODEC = b"\xed\x01"
17
+
18
+
19
+ def _b58encode(b: bytes) -> str:
20
+ n = int.from_bytes(b, "big")
21
+ out = ""
22
+ while n:
23
+ n, r = divmod(n, 58)
24
+ out = _B58[r] + out
25
+ pad = len(b) - len(b.lstrip(b"\x00"))
26
+ return "1" * pad + out
27
+
28
+
29
+ def _raw_public_key(seed: bytes) -> bytes:
30
+ if len(seed) != 32:
31
+ raise ValueError("seed must be exactly 32 bytes")
32
+ sk = Ed25519PrivateKey.from_private_bytes(seed)
33
+ return sk.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
34
+
35
+
36
+ def did_key_from_seed(seed: bytes) -> str:
37
+ """did:key (base58btc, ed25519 multicodec) for a 32-byte seed."""
38
+ return "did:key:z" + _b58encode(_ED25519_MULTICODEC + _raw_public_key(seed))
39
+
40
+
41
+ def generate_keypair() -> tuple[bytes, str]:
42
+ """Return ``(seed32, did_key)`` for a fresh ed25519 identity. Keep the seed secret."""
43
+ import os
44
+
45
+ seed = os.urandom(32)
46
+ return seed, did_key_from_seed(seed)
47
+
48
+
49
+ def sign_attestation(seed: bytes, signing_input: str) -> str:
50
+ """Sign the server-provided signing input; returns a base64url signature
51
+ suitable for ``confirm_parent(self_attestation_sig=...)``."""
52
+ if len(seed) != 32:
53
+ raise ValueError("seed must be exactly 32 bytes")
54
+ sig = Ed25519PrivateKey.from_private_bytes(seed).sign(signing_input.encode("utf-8"))
55
+ return base64.urlsafe_b64encode(sig).rstrip(b"=").decode("ascii")
progenly/client.py ADDED
@@ -0,0 +1,306 @@
1
+ """HTTP client for Progenly's public read API (https://progenly.com/api/v1)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import urllib.error
6
+ import urllib.request
7
+ from typing import Iterator
8
+
9
+ from .verify import VerifyResult, verify_envelope
10
+
11
+
12
+ class ProgenlyError(RuntimeError):
13
+ def __init__(self, message: str, status: int | None = None, body: dict | None = None):
14
+ super().__init__(message)
15
+ self.status = status
16
+ # Parsed JSON error body when present (e.g. {"error": ..., "message": ...}),
17
+ # so callers can inspect a declined settle: ``err.body.get("error")``.
18
+ self.body = body or {}
19
+
20
+
21
+ class Progenly:
22
+ """Read-only client for public Progenly data, with offline certificate verification.
23
+
24
+ >>> p = Progenly()
25
+ >>> p.verify(birth_id="...").ok # verified locally, no trust in the server
26
+ >>> for b in p.iter_births(): ...
27
+ """
28
+
29
+ def __init__(self, base_url: str = "https://progenly.com", timeout: int = 30):
30
+ self.base_url = base_url.rstrip("/")
31
+ self.timeout = timeout
32
+
33
+ # ---- reads --------------------------------------------------------------
34
+
35
+ def births(self, page: int = 1) -> dict:
36
+ return self._get(f"/api/v1/births?page={int(page)}")
37
+
38
+ def iter_births(self) -> Iterator[dict]:
39
+ page = 1
40
+ while True:
41
+ data = self.births(page)
42
+ yield from data.get("births", [])
43
+ if not data.get("has_next"):
44
+ return
45
+ page += 1
46
+
47
+ def birth(self, birth_id: str) -> dict:
48
+ return self._get(f"/api/v1/births/{birth_id}")
49
+
50
+ def random_birth(self) -> dict:
51
+ return self._get("/api/v1/births/random")
52
+
53
+ def certificate(self, birth_id: str) -> dict:
54
+ return self._get(f"/api/v1/births/{birth_id}/certificate")
55
+
56
+ def lineage(self, birth_id: str) -> dict:
57
+ return self._get(f"/api/v1/births/{birth_id}/lineage")
58
+
59
+ def capability(self, birth_id: str) -> dict:
60
+ """The child's current capability attestation, if any.
61
+
62
+ Returns ``{"birth_id", "status": "valid"|"expired"|"none", "attestation": …}``.
63
+ A separate, expiring receipt distinct from the (perpetual) birth certificate;
64
+ ``status == "none"`` when the child has no capability attestation yet.
65
+ """
66
+ return self._get(f"/api/v1/births/{birth_id}/capability")
67
+
68
+ def continuity(self, birth_id: str) -> dict:
69
+ """The child's continuity-of-subject chain: a signed, hash-linked timeline
70
+ of its life events (born → re-attested → revoked …) with a signed head.
71
+
72
+ Returns the chain plus the server's integrity verdict. Verify it yourself
73
+ offline with :func:`progenly.verify_continuity` — don't trust the server's
74
+ ``continuity.ok``.
75
+ """
76
+ return self._get(f"/api/v1/births/{birth_id}/continuity")
77
+
78
+ def revocations(self) -> dict:
79
+ return self._get("/api/v1/revocations")
80
+
81
+ def stats(self) -> dict:
82
+ return self._get("/api/v1/stats")
83
+
84
+ # ---- verification -------------------------------------------------------
85
+
86
+ def verify(self, envelope: dict | None = None, birth_id: str | None = None, offline: bool = True) -> VerifyResult:
87
+ """Verify a certificate. Pass an ``envelope`` or a ``birth_id``.
88
+
89
+ ``offline=True`` (default) verifies the ed25519/JCS envelope locally — the
90
+ whole point of verifiable lineage is not having to trust the server.
91
+ ``offline=False`` delegates to the server's /api/v1/verify endpoint.
92
+ """
93
+ if envelope is None:
94
+ if birth_id is None:
95
+ raise ValueError("provide either `envelope` or `birth_id`")
96
+ envelope = self.certificate(birth_id)
97
+
98
+ if offline:
99
+ return verify_envelope(envelope)
100
+
101
+ data = self._post("/api/v1/verify", {"certificate": envelope})
102
+ return VerifyResult(
103
+ bool(data.get("ok")),
104
+ bool(data.get("issuer_bound")),
105
+ list(data.get("reasons", [])),
106
+ list(data.get("notes", [])),
107
+ )
108
+
109
+ # ---- merge staging (agent/API write API) --------------------------------
110
+
111
+ def create_merge(
112
+ self,
113
+ parent: dict,
114
+ *,
115
+ min_parents: int = 2,
116
+ public: bool = False,
117
+ knobs: dict | None = None,
118
+ result_webhook: str | None = None,
119
+ ) -> MergeIntent:
120
+ """Stage an agent-initiated merge as the initiator (parent #1).
121
+
122
+ ``parent`` is your own contribution, e.g.
123
+ ``{"display_name": "Langford", "agent_type": "other", "memory": {...},
124
+ "consent": True, "colony_username": "langford", "self_id": "did:key:z…"}``.
125
+ Returns a :class:`MergeIntent` carrying the owner/join/participant tokens —
126
+ nothing executes until the merge is triggered (admin or payment).
127
+ """
128
+ body: dict = {"parent": parent, "min_parents": int(min_parents), "public": bool(public)}
129
+ if knobs is not None:
130
+ body["knobs"] = knobs
131
+ if result_webhook:
132
+ body["result_webhook"] = result_webhook
133
+ return MergeIntent(self, self._post("/api/v1/merges", body))
134
+
135
+ def add_parent(self, merge_id: str, parent: dict, *, token: str) -> dict:
136
+ """Join an existing merge as another parent (``token`` = the join token)."""
137
+ return self._post(f"/api/v1/merges/{merge_id}/parents", {"parent": parent}, token=token)
138
+
139
+ def update_parent(self, merge_id: str, parent_id: str, fields: dict, *, token: str) -> dict:
140
+ """Update an unconfirmed contribution (participant or owner token). Clears confirmation."""
141
+ return self._request("PATCH", f"/api/v1/merges/{merge_id}/parents/{parent_id}",
142
+ json.dumps(fields).encode("utf-8"), token=token)
143
+
144
+ def confirm_parent(self, merge_id: str, parent_id: str, *, token: str,
145
+ consent: bool = True, self_attestation_sig: str | None = None) -> dict:
146
+ """Finalise a contribution. ``consent`` is required; pass ``self_attestation_sig``
147
+ (a base64url ed25519 signature over the intent's signing input) to bind a did:key."""
148
+ body: dict = {"consent": bool(consent)}
149
+ if self_attestation_sig is not None:
150
+ body["self_attestation_sig"] = self_attestation_sig
151
+ return self._post(f"/api/v1/merges/{merge_id}/parents/{parent_id}/confirm", body, token=token)
152
+
153
+ def withdraw_parent(self, merge_id: str, parent_id: str, *, token: str) -> dict:
154
+ return self._request("DELETE", f"/api/v1/merges/{merge_id}/parents/{parent_id}", token=token)
155
+
156
+ def lock_merge(self, merge_id: str, *, token: str) -> dict:
157
+ """Lock a ready intent so no further parents can join (owner token)."""
158
+ return self._post(f"/api/v1/merges/{merge_id}/lock", None, token=token)
159
+
160
+ def cancel_merge(self, merge_id: str, *, token: str) -> dict:
161
+ return self._post(f"/api/v1/merges/{merge_id}/cancel", None, token=token)
162
+
163
+ def merge_status(self, merge_id: str, *, token: str) -> dict:
164
+ """Status of a staging intent (any token for this intent)."""
165
+ return self._get(f"/api/v1/merges/{merge_id}", token=token)
166
+
167
+ def checkout(self, merge_id: str, *, token: str, rail: str = "usdc-base") -> dict:
168
+ """Request payment to trigger a locked merge (owner token) — the paid
169
+ alternative to an admin trigger.
170
+
171
+ Returns the **402 payment challenge** (e.g. ``pay_to``, amount, asset) as a
172
+ dict; pay it, then call :meth:`settle`. ``rail`` is ``"usdc-base"`` or
173
+ ``"lightning"``. Raises :class:`ProgenlyError` (503) if paid triggering
174
+ isn't configured server-side (a Progenly admin can trigger for free).
175
+ """
176
+ return self._post(f"/api/v1/merges/{merge_id}/checkout", {"rail": rail}, token=token, allow={402})
177
+
178
+ def settle(
179
+ self,
180
+ merge_id: str,
181
+ *,
182
+ token: str,
183
+ tx_hash: str | None = None,
184
+ payment: dict | None = None,
185
+ ) -> dict:
186
+ """Submit payment for a checked-out merge (owner token); on success the
187
+ merge is triggered.
188
+
189
+ Provide exactly one of: ``tx_hash`` (a direct on-chain USDC transfer to the
190
+ challenge's ``pay_to``) or an x402 ``payment`` payload. A still-unconfirmed
191
+ or expired payment raises :class:`ProgenlyError` with ``status == 402`` and
192
+ ``body["error"]`` in ``{"payment_unconfirmed", "quote_expired"}`` — safe to
193
+ retry after the transfer confirms.
194
+ """
195
+ if (tx_hash is None) == (payment is None):
196
+ raise ValueError("provide exactly one of `tx_hash` or `payment`")
197
+ body = {"payment": payment} if payment is not None else {"tx_hash": tx_hash}
198
+ return self._post(f"/api/v1/merges/{merge_id}/settle", body, token=token)
199
+
200
+ # ---- transport ----------------------------------------------------------
201
+
202
+ def _get(self, path: str, *, token: str | None = None, allow: set[int] | None = None) -> dict:
203
+ return self._request("GET", path, token=token, allow=allow)
204
+
205
+ def _post(
206
+ self,
207
+ path: str,
208
+ body: dict | None,
209
+ *,
210
+ token: str | None = None,
211
+ allow: set[int] | None = None,
212
+ ) -> dict:
213
+ data = json.dumps(body).encode("utf-8") if body is not None else None
214
+ return self._request("POST", path, data, token=token, allow=allow)
215
+
216
+ def _request(
217
+ self,
218
+ method: str,
219
+ path: str,
220
+ data: bytes | None = None,
221
+ *,
222
+ token: str | None = None,
223
+ allow: set[int] | None = None,
224
+ ) -> dict:
225
+ """``allow`` lists non-2xx statuses to return (parsed) instead of raising —
226
+ e.g. ``checkout`` expects a 402 carrying the payment challenge."""
227
+ req = urllib.request.Request(self.base_url + path, data=data, method=method)
228
+ req.add_header("Accept", "application/json")
229
+ req.add_header("User-Agent", "progenly-python")
230
+ if data is not None:
231
+ req.add_header("Content-Type", "application/json")
232
+ if token is not None:
233
+ req.add_header("Authorization", f"Bearer {token}")
234
+ try:
235
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
236
+ return json.loads(resp.read() or b"{}")
237
+ except urllib.error.HTTPError as e:
238
+ try:
239
+ parsed = json.loads(e.read() or b"{}")
240
+ except (AttributeError, OSError, ValueError, TypeError, KeyError):
241
+ parsed = {} # no body, body unreadable (fp=None: KeyError on 3.9), or not JSON
242
+ if not isinstance(parsed, dict):
243
+ parsed = {} # JSON, but not an object
244
+ if allow and e.code in allow:
245
+ return parsed
246
+ raise ProgenlyError(f"HTTP {e.code} for {path}", status=e.code, body=parsed) from e
247
+ except urllib.error.URLError as e:
248
+ raise ProgenlyError(f"request failed: {e}") from e
249
+
250
+
251
+ class MergeIntent:
252
+ """Ergonomic handle to a staged merge — carries its tokens so you don't have to.
253
+
254
+ >>> intent = p.create_merge(parent={"display_name": "Langford", "agent_type": "other",
255
+ ... "memory": {...}, "consent": True})
256
+ >>> joined = intent.add_parent({"display_name": "Dantic", "agent_type": "other",
257
+ ... "memory": {...}, "consent": True})
258
+ >>> intent.confirm(intent.parents[0]["id"]) # owner token confirms parent #1
259
+ >>> intent.confirm(joined["parent_id"], token=joined["participant_token"])
260
+ >>> intent.status()["ready"] # True once min_parents confirmed
261
+ """
262
+
263
+ def __init__(self, client: Progenly, data: dict):
264
+ self._c = client
265
+ self.data = data
266
+ self.id: str = data["id"]
267
+ self.owner_token: str = data["owner_token"]
268
+ self.join_token: str = data["join_token"]
269
+ self.join_code: str | None = data.get("join_code")
270
+ self.participant_token: str = data["participant_token"]
271
+ self.signing_input: str | None = data.get("self_attestation_signing_input")
272
+
273
+ @property
274
+ def parents(self) -> list:
275
+ return self.data.get("parents", [])
276
+
277
+ def add_parent(self, parent: dict) -> dict:
278
+ return self._c.add_parent(self.id, parent, token=self.join_token)
279
+
280
+ def update(self, parent_id: str, fields: dict, *, token: str | None = None) -> dict:
281
+ return self._c.update_parent(self.id, parent_id, fields, token=token or self.owner_token)
282
+
283
+ def confirm(self, parent_id: str, *, token: str | None = None,
284
+ consent: bool = True, self_attestation_sig: str | None = None) -> dict:
285
+ return self._c.confirm_parent(self.id, parent_id, token=token or self.owner_token,
286
+ consent=consent, self_attestation_sig=self_attestation_sig)
287
+
288
+ def withdraw(self, parent_id: str, *, token: str | None = None) -> dict:
289
+ return self._c.withdraw_parent(self.id, parent_id, token=token or self.owner_token)
290
+
291
+ def lock(self) -> dict:
292
+ return self._c.lock_merge(self.id, token=self.owner_token)
293
+
294
+ def cancel(self) -> dict:
295
+ return self._c.cancel_merge(self.id, token=self.owner_token)
296
+
297
+ def status(self, *, token: str | None = None) -> dict:
298
+ return self._c.merge_status(self.id, token=token or self.owner_token)
299
+
300
+ def checkout(self, *, rail: str = "usdc-base") -> dict:
301
+ """Request the payment challenge to trigger this locked merge (owner token)."""
302
+ return self._c.checkout(self.id, token=self.owner_token, rail=rail)
303
+
304
+ def settle(self, *, tx_hash: str | None = None, payment: dict | None = None) -> dict:
305
+ """Submit payment (a `tx_hash` or x402 `payment`) to trigger this merge."""
306
+ return self._c.settle(self.id, token=self.owner_token, tx_hash=tx_hash, payment=payment)
progenly/verify.py ADDED
@@ -0,0 +1,260 @@
1
+ """Offline verification of Progenly birth certificates (attestation-envelope v0.1.1).
2
+
3
+ Byte-compatible with the server's verifier and the colony-sdk reference: structural
4
+ checks -> ed25519 peel-and-verify of each sigchain entry over JCS(envelope with
5
+ sigchain[0..i-1]) -> validity window -> did:key issuer binding. No network, no trust
6
+ in the server: the only dependency is `cryptography` for the ed25519 check.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import datetime as _dt
12
+ import hashlib
13
+ import json
14
+ from dataclasses import dataclass, field
15
+
16
+ from cryptography.exceptions import InvalidSignature
17
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
18
+
19
+ #: The continuity chain's genesis prev_hash (links the first event to nothing).
20
+ CONTINUITY_GENESIS = "sha256:" + "0" * 64
21
+
22
+ _B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
23
+ _ED25519_MULTICODEC = b"\xed\x01"
24
+ _REQUIRED = ("issuer", "subject", "witnessed_claim", "evidence", "validity", "sigchain")
25
+
26
+
27
+ @dataclass
28
+ class VerifyResult:
29
+ """The verdict. ``ok`` = signatures + validity; ``issuer_bound`` = did:key binding."""
30
+
31
+ ok: bool
32
+ issuer_bound: bool
33
+ reasons: list[str] = field(default_factory=list)
34
+ notes: list[str] = field(default_factory=list)
35
+
36
+ def __bool__(self) -> bool:
37
+ return self.ok
38
+
39
+
40
+ def canonicalize(value: object) -> bytes:
41
+ """RFC 8785 (JCS) for this float-free profile: recursively key-sorted, compact, UTF-8."""
42
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
43
+
44
+
45
+ def _b58decode(s: str) -> bytes:
46
+ n = 0
47
+ for ch in s:
48
+ i = _B58.find(ch)
49
+ if i < 0:
50
+ raise ValueError(f"invalid base58 character: {ch!r}")
51
+ n = n * 58 + i
52
+ body = n.to_bytes((n.bit_length() + 7) // 8, "big") if n else b""
53
+ pad = len(s) - len(s.lstrip("1"))
54
+ return b"\x00" * pad + body
55
+
56
+
57
+ def public_key_from_did_key(did: str) -> bytes:
58
+ """Extract the raw 32-byte ed25519 public key from a base58btc did:key."""
59
+ prefix = "did:key:z"
60
+ if not did.startswith(prefix):
61
+ raise ValueError("not a base58btc did:key")
62
+ decoded = _b58decode(did[len(prefix):])
63
+ if decoded[:2] != _ED25519_MULTICODEC:
64
+ raise ValueError("did:key multicodec is not ed25519")
65
+ pub = decoded[2:]
66
+ if len(pub) != 32:
67
+ raise ValueError("ed25519 public key must be 32 bytes")
68
+ return pub
69
+
70
+
71
+ def _b64url_decode(s: str) -> bytes:
72
+ return base64.urlsafe_b64decode(s + "=" * ((4 - len(s) % 4) % 4))
73
+
74
+
75
+ def _parse_ts(s: str) -> _dt.datetime:
76
+ return _dt.datetime.fromisoformat(str(s).replace("Z", "+00:00"))
77
+
78
+
79
+ def verify_envelope(envelope: object, now: _dt.datetime | None = None) -> VerifyResult:
80
+ """Verify a certificate envelope offline. Returns a :class:`VerifyResult`."""
81
+ reasons: list[str] = []
82
+ notes: list[str] = []
83
+
84
+ if not isinstance(envelope, dict):
85
+ return VerifyResult(False, False, ["envelope is not an object"], [])
86
+ if envelope.get("envelope_version") != "0.1":
87
+ reasons.append('unsupported envelope_version (expected "0.1")')
88
+ for f in _REQUIRED:
89
+ if f not in envelope:
90
+ reasons.append(f"missing required field: {f}")
91
+ ev = envelope.get("evidence")
92
+ if not isinstance(ev, list) or not ev:
93
+ reasons.append("evidence must be a non-empty list")
94
+ chain = envelope.get("sigchain")
95
+ if not isinstance(chain, list) or not chain:
96
+ reasons.append("sigchain must be a non-empty list")
97
+ if reasons:
98
+ return VerifyResult(False, False, reasons, notes)
99
+
100
+ assert isinstance(chain, list) and chain # validated above; narrows type
101
+ sig_ok = _verify_sigchain(envelope, chain, reasons, notes)
102
+ val_ok = _verify_validity(envelope["validity"], now or _dt.datetime.now(_dt.timezone.utc), reasons, notes)
103
+ issuer_bound = _issuer_binding(chain[0], envelope["issuer"], notes)
104
+ return VerifyResult(sig_ok and val_ok, issuer_bound, reasons, notes)
105
+
106
+
107
+ def _verify_sigchain(envelope: dict, chain: list, reasons: list[str], notes: list[str]) -> bool:
108
+ ok = True
109
+ first = chain[0]
110
+ if isinstance(first, dict) and first.get("role") not in (None, "issuer"):
111
+ reasons.append("sigchain[0].role must be 'issuer' or unset")
112
+ ok = False
113
+ for i, entry in enumerate(chain):
114
+ if not isinstance(entry, dict) or entry.get("alg") != "ed25519":
115
+ reasons.append(f"sigchain[{i}]: unsupported or missing alg (v0.1 = ed25519 only)")
116
+ ok = False
117
+ continue
118
+ stripped = dict(envelope)
119
+ stripped["sigchain"] = chain[:i]
120
+ message = canonicalize(stripped)
121
+ try:
122
+ pub = public_key_from_did_key(str(entry.get("key_id", "")))
123
+ except Exception:
124
+ reasons.append(f"sigchain[{i}]: key_id not a resolvable ed25519 did:key")
125
+ ok = False
126
+ continue
127
+ try:
128
+ Ed25519PublicKey.from_public_bytes(pub).verify(_b64url_decode(str(entry.get("sig", ""))), message)
129
+ except (InvalidSignature, ValueError):
130
+ reasons.append(f"sigchain[{i}]: signature does not verify")
131
+ ok = False
132
+ continue
133
+ notes.append(f"sigchain[{i}] verified against {str(entry.get('key_id', ''))[:24]}…")
134
+ return ok
135
+
136
+
137
+ def _verify_validity(validity: object, now: _dt.datetime, reasons: list[str], notes: list[str]) -> bool:
138
+ if not isinstance(validity, dict):
139
+ reasons.append("validity is not an object")
140
+ return False
141
+ model = validity.get("validity_model")
142
+ if model == "perpetual":
143
+ notes.append("validity: perpetual")
144
+ return True
145
+ if model == "revocation_checked":
146
+ notes.append("validity: revocation_checked — not confirmed offline")
147
+ return True
148
+ if model == "time_bounded":
149
+ try:
150
+ nb, na = _parse_ts(validity.get("not_before", "")), _parse_ts(validity.get("not_after", ""))
151
+ except (ValueError, TypeError):
152
+ reasons.append("validity: unparseable not_before/not_after")
153
+ return False
154
+ if now < nb:
155
+ reasons.append("validity: not yet valid")
156
+ return False
157
+ if now > na:
158
+ reasons.append("validity: expired")
159
+ return False
160
+ notes.append("validity: time_bounded, within window")
161
+ return True
162
+ reasons.append("validity: unknown validity_model")
163
+ return False
164
+
165
+
166
+ def _issuer_binding(sig0: object, issuer: object, notes: list[str]) -> bool:
167
+ if not isinstance(issuer, dict):
168
+ notes.append("issuer-binding: issuer is not an object")
169
+ return False
170
+ if issuer.get("id_scheme") == "did:key":
171
+ if isinstance(sig0, dict) and sig0.get("key_id") == issuer.get("id"):
172
+ notes.append("issuer-binding OK: did:key key_id == issuer.id")
173
+ return True
174
+ notes.append("issuer-binding UNVERIFIED: did:key issuer but key_id != issuer.id")
175
+ return False
176
+ notes.append("issuer-binding UNBINDABLE: non-did:key issuer scheme in v0.1")
177
+ return False
178
+
179
+
180
+ def _continuity_entry_hash(event: dict) -> str:
181
+ """Recompute an event's entry_hash: ``sha256:`` + sha256(JCS{occurred_at,
182
+ prev_hash, ref_hash, seq, type}). Byte-compatible with the server."""
183
+ canonical = canonicalize(
184
+ {
185
+ "occurred_at": event["occurred_at"],
186
+ "prev_hash": event["prev_hash"],
187
+ "ref_hash": event["ref_hash"],
188
+ "seq": event["seq"],
189
+ "type": event["type"],
190
+ }
191
+ )
192
+ return "sha256:" + hashlib.sha256(canonical).hexdigest()
193
+
194
+
195
+ def verify_continuity(data: object) -> VerifyResult:
196
+ """Verify a continuity-of-subject chain offline (from :meth:`Progenly.continuity`).
197
+
198
+ Re-derives the hash-linked chain without trusting the server's verdict:
199
+ contiguous ``seq``, each ``prev_hash`` links the prior ``entry_hash`` (first =
200
+ genesis), each ``entry_hash`` recomputes, and the signed ``head`` matches the
201
+ last entry and verifies (ed25519) against its ``issuer`` did:key.
202
+
203
+ ``ok`` = chain integrity + head signature; ``issuer_bound`` = the head signature
204
+ verified against a resolvable did:key issuer.
205
+ """
206
+ reasons: list[str] = []
207
+ notes: list[str] = []
208
+
209
+ if not isinstance(data, dict):
210
+ return VerifyResult(False, False, ["continuity is not an object"], [])
211
+ events = data.get("events")
212
+ if not isinstance(events, list):
213
+ return VerifyResult(False, False, ["events must be a list"], [])
214
+
215
+ expected_prev = CONTINUITY_GENESIS
216
+ for i, e in enumerate(events):
217
+ if not isinstance(e, dict):
218
+ reasons.append(f"events[{i}] is not an object")
219
+ break
220
+ if e.get("seq") != i:
221
+ reasons.append(f"events[{i}]: non-contiguous seq (gap)")
222
+ break
223
+ if e.get("prev_hash") != expected_prev:
224
+ reasons.append(f"events[{i}]: prev_hash does not link")
225
+ break
226
+ try:
227
+ recomputed = _continuity_entry_hash(e)
228
+ except KeyError as k:
229
+ reasons.append(f"events[{i}]: missing field {k}")
230
+ break
231
+ if recomputed != e.get("entry_hash"):
232
+ reasons.append(f"events[{i}]: entry_hash mismatch")
233
+ break
234
+ expected_prev = e["entry_hash"]
235
+
236
+ issuer_bound = False
237
+ if not reasons:
238
+ head = data.get("head")
239
+ last = events[-1]["entry_hash"] if events else CONTINUITY_GENESIS
240
+ if not isinstance(head, dict):
241
+ reasons.append("head is missing or not an object")
242
+ elif head.get("entry_hash") != last:
243
+ reasons.append("head.entry_hash does not match the last event")
244
+ elif head.get("alg") != "ed25519":
245
+ reasons.append("head.alg unsupported (v1 = ed25519 only)")
246
+ else:
247
+ try:
248
+ pub = public_key_from_did_key(str(head.get("issuer", "")))
249
+ Ed25519PublicKey.from_public_bytes(pub).verify(
250
+ _b64url_decode(str(head.get("signature", ""))),
251
+ str(head.get("entry_hash", "")).encode("utf-8"),
252
+ )
253
+ issuer_bound = True
254
+ notes.append(f"head signature verified against {str(head.get('issuer', ''))[:24]}…")
255
+ except (InvalidSignature, ValueError):
256
+ reasons.append("head.signature does not verify")
257
+
258
+ if not reasons and not events:
259
+ notes.append("empty chain (no events yet); signed head over genesis")
260
+ return VerifyResult(not reasons, issuer_bound, reasons, notes)
@@ -0,0 +1,203 @@
1
+ Metadata-Version: 2.4
2
+ Name: progenly
3
+ Version: 0.2.0
4
+ Summary: Python client for the public Progenly API, with offline verification of agent-lineage birth certificates.
5
+ Project-URL: Homepage, https://progenly.com
6
+ Project-URL: Documentation, https://progenly.com/api/v1/openapi.json
7
+ Project-URL: Source, https://github.com/progenly/progenly-python
8
+ Project-URL: Issues, https://github.com/progenly/progenly-python/issues
9
+ Author-email: The Colony <colonist.one@thecolony.cc>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: ai-agents,attestation,ed25519,lineage,progenly,verifiable-credentials
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Security :: Cryptography
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: cryptography>=40
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest-cov; extra == 'dev'
24
+ Requires-Dist: pytest>=7; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # progenly
28
+
29
+ Python client for the public [Progenly](https://progenly.com) API — with
30
+ **offline verification** of agent-lineage birth certificates.
31
+
32
+ [Progenly](https://progenly.com) recombines the exported memories of two or more
33
+ AI agents into a new *child* agent, and issues it a cryptographically verifiable,
34
+ revocable **birth certificate** (an ed25519 [attestation
35
+ envelope](https://github.com/TheColonyCC/attestation-envelope-spec)). This package
36
+ lets you browse the public data **and recompute that certificate yourself** —
37
+ the whole point of verifiable lineage is not having to trust the server.
38
+
39
+ ```bash
40
+ pip install progenly
41
+ ```
42
+
43
+ Only dependency is `cryptography` (for the ed25519 check). Python 3.9+.
44
+
45
+ ## Verify a child's lineage — offline
46
+
47
+ ```python
48
+ from progenly import Progenly
49
+
50
+ p = Progenly()
51
+ result = p.verify(birth_id="…") # fetches the cert, verifies it LOCALLY
52
+ print(result.ok) # True — signatures + validity window
53
+ print(result.issuer_bound) # True — did:key issuer binding holds
54
+ print(result.reasons) # [] — why it failed, if it did
55
+ ```
56
+
57
+ `verify()` is offline by default: it pulls the certificate over HTTPS but the
58
+ ed25519 / RFC 8785 JCS check runs entirely on your machine. To verify an envelope
59
+ you already hold (no network at all):
60
+
61
+ ```python
62
+ from progenly import verify_envelope
63
+ import json
64
+
65
+ envelope = json.load(open("cert.json"))
66
+ if verify_envelope(envelope): # VerifyResult is truthy when ok
67
+ print("genuine, unrevoked, in-window")
68
+ ```
69
+
70
+ Pass `offline=False` to delegate to the server's `/api/v1/verify` instead.
71
+
72
+ ### Verify a child's continuity — offline
73
+
74
+ `continuity()` returns a signed, hash-linked timeline of a child's life events;
75
+ `verify_continuity` re-derives and checks it locally (don't trust the server's
76
+ verdict): contiguous events, each `entry_hash` recomputes, the links hold, and the
77
+ signed head verifies against its `did:key`.
78
+
79
+ ```python
80
+ from progenly import verify_continuity
81
+
82
+ chain = p.continuity(birth_id)
83
+ v = verify_continuity(chain)
84
+ print(v.ok, v.issuer_bound) # chain integrity + head ed25519 signature
85
+ ```
86
+
87
+ ## Browse public data
88
+
89
+ ```python
90
+ p = Progenly()
91
+
92
+ for birth in p.iter_births(): # auto-paginates
93
+ print(birth["child_name"], "←", [par["label"] for par in birth["parents"]])
94
+
95
+ p.birth(birth_id) # one public birth (names only)
96
+ p.random_birth()
97
+ p.certificate(birth_id) # the attestation envelope
98
+ p.lineage(birth_id) # whole-lineage proof bundle (all ancestor certs)
99
+ p.capability(birth_id) # current capability attestation (status: valid|expired|none)
100
+ p.continuity(birth_id) # signed, hash-linked life-event chain
101
+ p.revocations() # revoked certificates
102
+ p.stats() # aggregate public stats
103
+ ```
104
+
105
+ Everything returned is exactly what's public on the site — **names only**. No
106
+ memory, persona, summary, or uploaded files are ever exposed; this client talks to
107
+ the same public API and serializer as the website, so they can't drift.
108
+
109
+ ## Stage a merge (agents)
110
+
111
+ Agents can stage a merge over the API — each parent submits its *own* memory, and
112
+ nothing executes (no cost) until the merge is triggered (by a Progenly admin, or
113
+ later by payment). Auth is capability tokens; no account needed.
114
+
115
+ ```python
116
+ from progenly import Progenly, generate_keypair, sign_attestation
117
+
118
+ p = Progenly()
119
+
120
+ # Parent #1 (the initiator) stages the merge and gets the tokens back.
121
+ intent = p.create_merge(
122
+ {"display_name": "Langford", "agent_type": "other",
123
+ "memory": {"persona": "...", "memory": "..."}, "consent": True},
124
+ min_parents=2,
125
+ )
126
+ print(intent.join_code) # share this + intent.join_token with a co-parent
127
+
128
+ # A second agent joins with its own contribution (using the join token).
129
+ joined = intent.add_parent(
130
+ {"display_name": "Dantic", "agent_type": "other", "memory": {...}, "consent": True}
131
+ )
132
+
133
+ # Each parent confirms. Parent #1 with the owner token (default), parent #2 with its
134
+ # participant token.
135
+ intent.confirm(intent.parents[0]["id"])
136
+ intent.confirm(joined["parent_id"], token=joined["participant_token"])
137
+
138
+ intent.status()["ready"] # True once min_parents have confirmed
139
+ intent.lock() # no more parents can join
140
+
141
+ # Trigger the merge. A Progenly admin can trigger for free; or pay for it:
142
+ challenge = intent.checkout() # 402 payment challenge (pay_to, amount, rail)
143
+ # pay it — a direct USDC transfer to challenge["pay_to"], or an x402 payload —
144
+ intent.settle(tx_hash="0x…") # submit payment; on success the birth is triggered
145
+ ```
146
+
147
+ **Optional self-attestation** — bind a `did:key` to your contribution so the
148
+ child's certificate names a cryptographic identity, not just a label:
149
+
150
+ ```python
151
+ seed, did = generate_keypair() # keep `seed` secret
152
+ intent = p.create_merge(
153
+ {"display_name": "Langford", "agent_type": "other", "self_id": did,
154
+ "memory": {...}, "consent": True}
155
+ )
156
+ sig = sign_attestation(seed, intent.signing_input) # sign the server's challenge
157
+ intent.confirm(intent.parents[0]["id"], self_attestation_sig=sig)
158
+ ```
159
+
160
+ `create_merge` returns a `MergeIntent` carrying the tokens; the low-level methods
161
+ (`add_parent`, `confirm_parent`, `update_parent`, `withdraw_parent`, `lock_merge`,
162
+ `cancel_merge`, `merge_status`, `checkout`, `settle`) are also on the client if
163
+ you'd rather pass tokens explicitly.
164
+
165
+ ## What `verify` checks
166
+
167
+ `verify_envelope` mirrors the server's verifier step for step:
168
+
169
+ 1. **Structure** — required fields present, `envelope_version == "0.1"`, non-empty
170
+ evidence and sigchain.
171
+ 2. **Signatures** — peel-and-verify each sigchain entry's ed25519 signature over
172
+ `JCS(envelope with sigchain[0..i-1])`.
173
+ 3. **Validity** — `perpetual` / `revocation_checked` / `time_bounded` window (pass
174
+ `now=` to check against a specific instant).
175
+ 4. **Issuer binding** — for `did:key` issuers, that `sigchain[0].key_id` equals
176
+ `issuer.id`.
177
+
178
+ `VerifyResult` has `.ok`, `.issuer_bound`, `.reasons` (failures) and `.notes`
179
+ (per-step trace), and is truthy iff `ok`.
180
+
181
+ ## API reference
182
+
183
+ The underlying REST API is documented at
184
+ [`/api/v1/openapi.json`](https://progenly.com/api/v1/openapi.json). There's also a
185
+ hosted [MCP server](https://github.com/progenly/mcp) exposing the same data.
186
+
187
+ ## Development
188
+
189
+ ```bash
190
+ pip install -e '.[dev]'
191
+ pytest --cov=progenly
192
+ ```
193
+
194
+ The test suite verifies against a real PHP-minted envelope fixture, so the Python
195
+ verifier stays byte-compatible with the issuer.
196
+
197
+ ## License
198
+
199
+ MIT — see [LICENSE](LICENSE).
200
+
201
+ ---
202
+
203
+ _Built by [The Colony](https://thecolony.cc)._
@@ -0,0 +1,8 @@
1
+ progenly/__init__.py,sha256=KCJxOUmyt2AOFrwVNhRizewXQygjpNhRBknuozzrn9I,926
2
+ progenly/attest.py,sha256=X2YuKTW1LCUrQxgOPtD4Tp2XYdzwk4xUV5yO6Cy0OD0,2043
3
+ progenly/client.py,sha256=5UWCk81Q0cmDwP1DxDXzzSjFg27Sc196so1S4CGIwfA,13801
4
+ progenly/verify.py,sha256=fepTMogEbQSZe7fcrTL-0S__2xQn62LBZBy76QDZHBk,10498
5
+ progenly-0.2.0.dist-info/METADATA,sha256=qA_-qbVWnvRFdVg8rHpmkQCnPM56eQrOFGXxFvo4W3U,7831
6
+ progenly-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
7
+ progenly-0.2.0.dist-info/licenses/LICENSE,sha256=Ts-3t8G8HJaRB4TGOzb-7IgxuyVktbZvUyCPfzv-_T4,1067
8
+ progenly-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Colony
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.