passiveworkers 0.1.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.
council/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Passive Workers — the Council: mutual-aid collective-intelligence MVP."""
council/artifacts.py ADDED
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ council/artifacts.py — content-addressed, chunked, integrity-verified file delivery (D22)
4
+ =========================================================================================
5
+ Real marketplace work produces FILES, not just text. This is the lean, dependency-free
6
+ codec for moving them between machines safely (FEDERATION_V2 step 3):
7
+
8
+ • split a file into fixed-size chunks, hash each (sha256) → the chunk is its own address
9
+ • a manifest records {name, size, chunk_size, root, chunks:[hashes]}; `root` = sha256 of
10
+ the ordered chunk hashes (a flat Merkle root) — the tamper-evident fingerprint
11
+ • the coordinator stores chunks as OPAQUE content-addressed blobs (dedup for free)
12
+ • the receiver fetches each chunk by hash, verifies it against the manifest, and only
13
+ reassembles if every chunk and the root check out — a corrupted or swapped chunk is
14
+ detected, not written
15
+
16
+ Encryption + producer signatures layer on top of this later (the [crypto] extra); the
17
+ content-addressing here already gives integrity. Stdlib only.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import pathlib
24
+
25
+ CHUNK_SIZE = 256 * 1024 # 256 KiB chunks
26
+ MAX_FILE_BYTES = 50 * 1024 * 1024 # 50 MiB per deliverable file (v1 cap)
27
+
28
+
29
+ _ARTIFACT_TAG = "__pw_artifact__"
30
+
31
+
32
+ def _h(data: bytes) -> str:
33
+ return hashlib.sha256(data).hexdigest()
34
+
35
+
36
+ def wrap_artifact(manifest: dict) -> str:
37
+ """Serialize a manifest as a file-deliverable, tagged so it can't be confused with a
38
+ user's text deliverable that merely happens to be JSON."""
39
+ import json
40
+ return json.dumps({_ARTIFACT_TAG: 1, "manifest": manifest})
41
+
42
+
43
+ def read_artifact(deliverable: str):
44
+ """Return the manifest if `deliverable` is a tagged file artifact, else None (it's text)."""
45
+ import json
46
+ try:
47
+ d = json.loads(deliverable)
48
+ except Exception:
49
+ return None
50
+ if isinstance(d, dict) and d.get(_ARTIFACT_TAG) == 1 and isinstance(d.get("manifest"), dict):
51
+ return d["manifest"]
52
+ return None
53
+
54
+
55
+ def manifest_root(chunk_hashes: list[str]) -> str:
56
+ """Flat Merkle root: sha256 over the ordered chunk hashes. Order matters (reassembly)."""
57
+ return hashlib.sha256("".join(chunk_hashes).encode()).hexdigest()
58
+
59
+
60
+ def chunk_file(path: str) -> tuple[dict, dict[str, bytes]]:
61
+ """(manifest, {hash: bytes}). Raises if the file is missing or over the size cap."""
62
+ p = pathlib.Path(path).expanduser().resolve()
63
+ if not p.is_file():
64
+ raise ValueError(f"not a file: {path}")
65
+ size = p.stat().st_size
66
+ if size > MAX_FILE_BYTES:
67
+ raise ValueError(f"file too large ({size // 1_000_000} MB > {MAX_FILE_BYTES // 1_000_000} MB)")
68
+ blobs: dict[str, bytes] = {}
69
+ order: list[str] = []
70
+ with p.open("rb") as f:
71
+ while True:
72
+ buf = f.read(CHUNK_SIZE)
73
+ if not buf:
74
+ break
75
+ h = _h(buf)
76
+ blobs[h] = buf
77
+ order.append(h)
78
+ manifest = {"name": p.name, "size": size, "chunk_size": CHUNK_SIZE,
79
+ "chunks": order, "root": manifest_root(order)}
80
+ return manifest, blobs
81
+
82
+
83
+ _HEX64 = __import__("re").compile(r"^[0-9a-f]{64}$")
84
+
85
+
86
+ def chunk_file_encrypted(path: str, seal_fn) -> tuple[dict, dict[str, bytes]]:
87
+ """Like chunk_file, but each plaintext chunk is encrypted via seal_fn(bytes)->bytes before
88
+ hashing/storing. The blob address is the hash of the CIPHERTEXT (so content-addressing and
89
+ integrity still apply); the manifest is flagged `encrypted` and its size is the PLAINTEXT
90
+ size. The coordinator only ever stores ciphertext it cannot read (D23)."""
91
+ p = pathlib.Path(path).expanduser().resolve()
92
+ if not p.is_file():
93
+ raise ValueError(f"not a file: {path}")
94
+ size = p.stat().st_size
95
+ if size > MAX_FILE_BYTES:
96
+ raise ValueError(f"file too large ({size // 1_000_000} MB > {MAX_FILE_BYTES // 1_000_000} MB)")
97
+ blobs: dict[str, bytes] = {}
98
+ order: list[str] = []
99
+ with p.open("rb") as f:
100
+ while True:
101
+ buf = f.read(CHUNK_SIZE)
102
+ if not buf:
103
+ break
104
+ ct = seal_fn(buf)
105
+ h = _h(ct)
106
+ blobs[h] = ct
107
+ order.append(h)
108
+ manifest = {"name": p.name, "size": size, "chunk_size": CHUNK_SIZE,
109
+ "chunks": order, "root": manifest_root(order), "encrypted": True}
110
+ return manifest, blobs
111
+
112
+
113
+ def verify_manifest(manifest: dict) -> bool:
114
+ """The manifest's declared root must match its ordered chunk list, chunk hashes must be
115
+ well-formed, and size must be a non-negative int (catches a doctored manifest before we
116
+ fetch anything). An empty file (size 0, no chunks) is valid."""
117
+ chunks = manifest.get("chunks")
118
+ size = manifest.get("size")
119
+ if not isinstance(chunks, list) or not isinstance(size, int) or size < 0:
120
+ return False
121
+ if not all(isinstance(h, str) and _HEX64.match(h) for h in chunks):
122
+ return False
123
+ return manifest.get("root") == manifest_root(chunks)
124
+
125
+
126
+ def reassemble(manifest: dict, fetch_chunk, out_dir: str, decrypt=None) -> pathlib.Path:
127
+ """Fetch each chunk via fetch_chunk(hash)->bytes, VERIFY each against its hash, and write
128
+ the file into out_dir only if everything checks out. fetch_chunk failures or any hash
129
+ mismatch raise — a corrupted/swapped chunk never reaches disk. Path-safe: the manifest
130
+ name is reduced to a basename inside out_dir. If the manifest is `encrypted`, `decrypt`
131
+ (bytes->bytes) is applied AFTER the ciphertext hash is verified."""
132
+ if manifest.get("encrypted") and decrypt is None:
133
+ raise ValueError("manifest is encrypted but no decrypt key provided")
134
+ if not verify_manifest(manifest):
135
+ raise ValueError("manifest root does not match its chunk list")
136
+ out = pathlib.Path(out_dir).expanduser().resolve()
137
+ out.mkdir(parents=True, exist_ok=True)
138
+ name = pathlib.Path(str(manifest.get("name", ""))).name or "deliverable.bin" # strip traversal
139
+ dest = (out / name).resolve()
140
+ # segment-aware containment (not a bare startswith, which sibling-prefixes could fool)
141
+ if dest != out and out not in dest.parents:
142
+ raise ValueError("unsafe output path")
143
+ total = 0
144
+ encrypted = bool(manifest.get("encrypted"))
145
+ with dest.open("wb") as f:
146
+ for h in manifest["chunks"]:
147
+ data = fetch_chunk(h)
148
+ if data is None or _h(data) != h: # verify the (cipher)text against its address
149
+ raise ValueError(f"chunk {h[:12]}… missing or corrupted — aborting")
150
+ if encrypted:
151
+ try:
152
+ data = decrypt(data) # decrypt AFTER integrity check
153
+ except Exception:
154
+ dest.unlink(missing_ok=True)
155
+ raise ValueError("decryption failed (wrong key or tampered chunk)")
156
+ f.write(data)
157
+ total += len(data)
158
+ if manifest.get("size") is not None and total != manifest["size"]:
159
+ dest.unlink(missing_ok=True)
160
+ raise ValueError("reassembled size mismatch")
161
+ return dest
council/batch.py ADDED
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ council/batch.py — the per-node batch worker for `shard_map` jobs (D13/D15)
4
+ ============================================================================
5
+ One big job, split across computers: this node receives its SHARD of the items and
6
+ applies the instruction to each item with its local model. Wall-clock scales with
7
+ items ÷ computers — the honest "divide to save time" the marketplace sells.
8
+
9
+ With `fetch=True`, items are PUBLIC URLs: the node fetches each one itself (SSRF-guarded,
10
+ one polite request, size-capped) and returns the model's EXTRACTION — never the raw page.
11
+ The D15 bright line: a node only fetches what it could lawfully fetch alone, and only
12
+ value-added output leaves the node. No relaying, no rate-limit evasion.
13
+
14
+ Per-item failures never kill the shard — the item's output records the error and the
15
+ batch continues (the judge's spot-check and score reflect quality).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import time
22
+ from dataclasses import dataclass
23
+
24
+ import requests
25
+
26
+ from council.research import fetch_extract
27
+ from council.sanitize import clean, spotlight
28
+
29
+ OLLAMA_BASE = "http://localhost:11434"
30
+ _GEN_TIMEOUT = float(os.environ.get("PW_BATCH_GEN_TIMEOUT",
31
+ os.environ.get("PW_OLLAMA_TIMEOUT", "480")))
32
+
33
+
34
+ @dataclass
35
+ class BatchWorker:
36
+ worker_id: str
37
+ model: str
38
+ country: str = "local"
39
+ temperature: float = 0.2
40
+ ollama_base: str = OLLAMA_BASE
41
+
42
+ def _generate(self, prompt: str, num_predict: int = 220) -> tuple[str, int]:
43
+ r = requests.post(
44
+ f"{self.ollama_base}/api/generate",
45
+ json={"model": self.model, "prompt": prompt, "stream": False,
46
+ "options": {"temperature": self.temperature, "num_predict": num_predict},
47
+ # keep the model warm across this shard's items (R17) — batch loops _generate per item
48
+ "keep_alive": os.environ.get("PW_OLLAMA_KEEP_ALIVE", "30m")},
49
+ timeout=_GEN_TIMEOUT,
50
+ )
51
+ r.raise_for_status()
52
+ d = r.json()
53
+ return (d.get("response") or "").strip(), (d.get("eval_count") or 0)
54
+
55
+ def process(self, instruction: str, shard: list[dict], fetch: bool = False) -> dict:
56
+ t0 = time.monotonic()
57
+ # the instruction is the asker's TASK (kept as a directive) but still scrubbed of invisible/
58
+ # bidi/HTML-comment injection vectors; every per-item value is untrusted DATA → spotlighted.
59
+ instruction = clean(instruction)
60
+ results, tokens, ok = [], 0, 0
61
+ for entry in shard:
62
+ idx, item = entry.get("i", 0), str(entry.get("item", ""))
63
+ try:
64
+ if fetch:
65
+ content = fetch_extract(item)
66
+ prompt = (f"{instruction}\n\nSOURCE URL: {item}\n"
67
+ f"PAGE TEXT (extracted):\n{spotlight(content)}\n\nOUTPUT:")
68
+ else:
69
+ prompt = f"{instruction}\n\nITEM:\n{spotlight(item)}\n\nOUTPUT (concise):"
70
+ out, tk = self._generate(prompt)
71
+ tokens += tk
72
+ ok += 1
73
+ results.append({"i": idx, "item": item, "output": out})
74
+ except Exception as e:
75
+ results.append({"i": idx, "item": item,
76
+ "output": f"(error: {type(e).__name__}: {str(e)[:120]})"})
77
+ elapsed = round(time.monotonic() - t0, 2)
78
+ return {
79
+ "text": (f"Processed {ok}/{len(shard)} items in {elapsed}s on this "
80
+ f"{self.country} node ({self.model})."),
81
+ "results": results,
82
+ "tokens": tokens,
83
+ "elapsed_s": elapsed,
84
+ }
council/cli.py ADDED
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ council/cli.py — the `pw` command
4
+ ==================================
5
+ pw research "your brief" [--quick|--deep] [--editor api] [--analysts N] [--local|--web]
6
+ pw serve # local research desk at http://127.0.0.1:8770
7
+ pw library add <path|dir> # index your own documents (private, local RAG)
8
+ pw library list|remove|clear
9
+ pw mcp # run as an MCP server (Claude Desktop, Codex, …)
10
+ pw tasks # list open assisted offers you can do (federation)
11
+ pw accept <id> | deliver <id> <text|@file>
12
+ pw fetch <job> <dir> # download + verify a delivered file (asker)
13
+ pw rate <job> <0-10> # rate an assisted deliverable → operator reputation (asker)
14
+ pw fingerprint # print your signing key + fingerprint to share (operator)
15
+ pw trust add <op> <key> | list | remove <op> # pin operator keys out of band (asker)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import sys
21
+
22
+
23
+ def main() -> int:
24
+ args = sys.argv[1:]
25
+ if not args or args[0] in ("-h", "--help"):
26
+ print(__doc__.strip())
27
+ return 0
28
+ cmd, rest = args[0], args[1:]
29
+ if cmd == "research":
30
+ sys.argv = ["pw research"] + rest
31
+ from council.local import main as research_main
32
+ return research_main()
33
+ if cmd == "serve":
34
+ from council.serve import main as serve_main
35
+ serve_main()
36
+ return 0
37
+ if cmd == "library":
38
+ sys.argv = ["pw library"] + rest
39
+ from council.library import main as library_main
40
+ return library_main()
41
+ if cmd == "mcp":
42
+ from council.mcp_server import main as mcp_main
43
+ mcp_main()
44
+ return 0
45
+ if cmd in ("tasks", "accept", "deliver", "fetch", "keygen", "rate", "fingerprint", "trust"):
46
+ sys.argv = ["pw", cmd] + rest # operator.main dispatches on argv[1] (the verb)
47
+ from council.operator import main as operator_main
48
+ return operator_main()
49
+ print(f"unknown command: {cmd}\n\n{__doc__.strip()}")
50
+ return 2
51
+
52
+
53
+ if __name__ == "__main__":
54
+ sys.exit(main())
council/coordinator.py ADDED
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ council/coordinator.py — the orchestrator
4
+ =========================================
5
+ Ties the loop together:
6
+
7
+ intake → concurrent fan-out to a diverse fleet → judge scores + merges →
8
+ score-weighted ledger settlement → result bundle
9
+
10
+ The coordinator is the open-source, self-hostable hub. It holds the non-transferable
11
+ ledger and the worker registry; it never holds a tradeable token and never relays raw
12
+ traffic — it routes TASKS and settles CREDIT for delivered answers.
13
+
14
+ A worker is owned by a participant (its `owner`); credit for that worker's answer flows
15
+ to the owner's account. That is how "your machine helping on my question earns YOU
16
+ credit" works — and how a user who both asks and helps keeps their give/take balanced.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import time
22
+ from concurrent.futures import ThreadPoolExecutor, as_completed
23
+ from dataclasses import dataclass, field
24
+
25
+ from council.judge import Judge, ScoredCandidate
26
+ from council.ledger import InsufficientCredit, Ledger, Receipt
27
+ from council.worker import Answer, PerspectiveWorker
28
+
29
+ WORKER_POOL = 30.0 # credits split among helpers per job (by score)
30
+ JUDGE_FEE = 5.0 # credits to the judge per job
31
+
32
+
33
+ @dataclass
34
+ class CouncilResult:
35
+ job_id: str
36
+ question: str
37
+ asker_id: str
38
+ answers: list[Answer]
39
+ scored: list[ScoredCandidate]
40
+ merged_answer: str
41
+ receipt: Receipt
42
+ elapsed_s: float
43
+ owner_of: dict = field(default_factory=dict) # worker_id -> owner_id
44
+
45
+ def best_single(self) -> Answer:
46
+ best_wid = max(self.scored, key=lambda s: s.score).worker_id
47
+ return next(a for a in self.answers if a.worker_id == best_wid)
48
+
49
+ def score_for(self, worker_id: str) -> float:
50
+ return next((s.score for s in self.scored if s.worker_id == worker_id), 0.0)
51
+
52
+
53
+ @dataclass
54
+ class Council:
55
+ ledger: Ledger
56
+ judge: Judge
57
+ judge_owner_id: str = "judge_node"
58
+ worker_pool: float = WORKER_POOL
59
+ judge_fee: float = JUDGE_FEE
60
+ _job_seq: int = 0
61
+
62
+ def run(
63
+ self,
64
+ asker_id: str,
65
+ question: str,
66
+ fleet: list[PerspectiveWorker],
67
+ owner_of: dict | None = None,
68
+ ) -> CouncilResult:
69
+ owner_of = dict(owner_of or {})
70
+ for w in fleet:
71
+ owner_of.setdefault(w.worker_id, w.worker_id)
72
+
73
+ # Make sure every participant has an account.
74
+ self.ledger.open_account(asker_id)
75
+ self.ledger.open_account(self.judge_owner_id)
76
+ for w in fleet:
77
+ self.ledger.open_account(owner_of[w.worker_id])
78
+
79
+ # Affordability gate — the give/take rule in action.
80
+ cost = self.ledger.quote(self.worker_pool, self.judge_fee)
81
+ if not self.ledger.can_afford(asker_id, cost):
82
+ raise InsufficientCredit(
83
+ f"{asker_id} cannot afford {cost:.1f} credits "
84
+ f"(balance {self.ledger.balance(asker_id):.1f}). Help on a job first."
85
+ )
86
+
87
+ self._job_seq += 1
88
+ job_id = f"job{self._job_seq:03d}"
89
+ t0 = time.monotonic()
90
+
91
+ # Concurrent fan-out — every perspective answers in parallel.
92
+ answers: list[Answer] = []
93
+ with ThreadPoolExecutor(max_workers=len(fleet)) as pool:
94
+ futures = {pool.submit(w.answer, question): w for w in fleet}
95
+ for fut in as_completed(futures):
96
+ try:
97
+ answers.append(fut.result())
98
+ except Exception as exc:
99
+ w = futures[fut]
100
+ print(f" [coordinator] worker {w.worker_id} failed: {exc}")
101
+ # Stable order by worker_id for reproducible display.
102
+ answers.sort(key=lambda a: a.worker_id)
103
+
104
+ # Judge: score (blind) then merge.
105
+ scored = self.judge.score(question, answers)
106
+ merged = self.judge.merge(question, answers)
107
+
108
+ # Aggregate scores by OWNER (a user running >1 model sums their scores).
109
+ score_by_owner: dict[str, float] = {}
110
+ for sc in scored:
111
+ owner = owner_of[sc.worker_id]
112
+ score_by_owner[owner] = score_by_owner.get(owner, 0.0) + sc.score
113
+
114
+ receipt = self.ledger.settle_job(
115
+ job_id=job_id,
116
+ asker_id=asker_id,
117
+ score_by_worker=score_by_owner,
118
+ worker_pool=self.worker_pool,
119
+ judge_id=self.judge_owner_id,
120
+ judge_fee=self.judge_fee,
121
+ )
122
+
123
+ return CouncilResult(
124
+ job_id=job_id,
125
+ question=question,
126
+ asker_id=asker_id,
127
+ answers=answers,
128
+ scored=scored,
129
+ merged_answer=merged,
130
+ receipt=receipt,
131
+ elapsed_s=time.monotonic() - t0,
132
+ owner_of=owner_of,
133
+ )
council/crypto.py ADDED
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ council/crypto.py — optional cryptographic delivery (D23, FEDERATION_V2 step 2)
4
+ ================================================================================
5
+ Two real guarantees layered on top of content-addressed delivery (D22), both OPTIONAL
6
+ (the [crypto] extra; everything degrades gracefully to D22 integrity when PyNaCl is absent):
7
+
8
+ • SIGNING (Ed25519): an operator signs its deliverable with a private key the coordinator
9
+ never sees; the asker verifies against the operator's published public key. Detects any
10
+ post-delivery tampering of the content and binds the deliverable to the operator's key.
11
+ (Honest limit: a hostile coordinator that swaps content+key+sig together needs an
12
+ out-of-band key check / PKI to defeat — documented, not papered over.)
13
+ • ENCRYPTION (SealedBox / X25519): the asker publishes a public key with the job; the
14
+ operator seals the payload to it; only the asker's private key can open it. The
15
+ coordinator relays ciphertext it genuinely cannot read — a real confidentiality
16
+ guarantee even against a hostile coordinator.
17
+
18
+ Keys are base64 strings so they travel as plain JSON. Helpers persist a keypair per identity
19
+ in ~/.passiveworkers so sign/verify and seal/unseal survive across CLI invocations.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import json
26
+ import os
27
+ import pathlib
28
+
29
+ try:
30
+ from nacl.public import PrivateKey, PublicKey, SealedBox
31
+ from nacl.signing import SigningKey, VerifyKey
32
+ from nacl.exceptions import BadSignatureError, CryptoError
33
+ HAVE_CRYPTO = True
34
+ except Exception: # pragma: no cover - optional dependency
35
+ HAVE_CRYPTO = False
36
+
37
+
38
+ def available() -> bool:
39
+ return HAVE_CRYPTO
40
+
41
+
42
+ def _require() -> None:
43
+ if not HAVE_CRYPTO:
44
+ raise RuntimeError("the crypto extra is not installed: pip install 'passiveworkers[crypto]'")
45
+
46
+
47
+ def _b64e(b: bytes) -> str:
48
+ return base64.b64encode(b).decode()
49
+
50
+
51
+ def _b64d(s: str) -> bytes:
52
+ return base64.b64decode(s.encode(), validate=True) # reject whitespace/non-alphabet
53
+
54
+
55
+ # ------------------------------------------------------------------ keypairs
56
+ def new_signing_keypair() -> tuple[str, str]:
57
+ """(private_b64, public_b64) for Ed25519 signing."""
58
+ _require()
59
+ sk = SigningKey.generate()
60
+ return _b64e(bytes(sk)), _b64e(bytes(sk.verify_key))
61
+
62
+
63
+ def new_box_keypair() -> tuple[str, str]:
64
+ """(private_b64, public_b64) for X25519 sealed-box encryption."""
65
+ _require()
66
+ sk = PrivateKey.generate()
67
+ return _b64e(bytes(sk)), _b64e(bytes(sk.public_key))
68
+
69
+
70
+ def load_or_create(path: pathlib.Path, kind: str) -> dict:
71
+ """Persist+reuse a keypair of `kind` ('sign' or 'box') at `path`. Returns {priv, pub}."""
72
+ if path.exists():
73
+ try:
74
+ d = json.loads(path.read_text()).get(kind)
75
+ if d and d.get("priv") and d.get("pub"):
76
+ return d
77
+ except Exception:
78
+ pass
79
+ priv, pub = new_signing_keypair() if kind == "sign" else new_box_keypair()
80
+ path.parent.mkdir(parents=True, exist_ok=True)
81
+ allk = {}
82
+ if path.exists():
83
+ try:
84
+ allk = json.loads(path.read_text())
85
+ except Exception:
86
+ allk = {}
87
+ allk[kind] = {"priv": priv, "pub": pub}
88
+ # write with owner-only perms FROM CREATION (no world-readable window before chmod)
89
+ blob = json.dumps(allk).encode()
90
+ try:
91
+ fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
92
+ with os.fdopen(fd, "wb") as f:
93
+ f.write(blob)
94
+ os.chmod(path, 0o600)
95
+ except Exception:
96
+ path.write_text(json.dumps(allk)) # fallback (e.g. platforms without mode support)
97
+ try:
98
+ os.chmod(path, 0o600) # a private key must never be world-readable
99
+ except Exception:
100
+ pass
101
+ return allk[kind]
102
+
103
+
104
+ # ------------------------------------------------------------------ signing
105
+ def sign(priv_b64: str, data: bytes) -> str:
106
+ """Detached Ed25519 signature (b64) over `data`."""
107
+ _require()
108
+ return _b64e(SigningKey(_b64d(priv_b64)).sign(data).signature)
109
+
110
+
111
+ def verify(pub_b64: str, data: bytes, sig_b64: str) -> bool:
112
+ """True iff `sig` is a valid signature of `data` under `pub`. Never raises (returns False
113
+ on any error, including when the crypto extra is absent)."""
114
+ if not HAVE_CRYPTO:
115
+ return False
116
+ try:
117
+ VerifyKey(_b64d(pub_b64)).verify(data, _b64d(sig_b64))
118
+ return True
119
+ except Exception:
120
+ return False
121
+
122
+
123
+ # ------------------------------------------------------------------ encryption
124
+ def seal(recipient_pub_b64: str, data: bytes) -> bytes:
125
+ """Anonymous-sender encryption to a recipient public key (X25519 SealedBox)."""
126
+ _require()
127
+ return SealedBox(PublicKey(_b64d(recipient_pub_b64))).encrypt(data)
128
+
129
+
130
+ def unseal(priv_b64: str, ciphertext: bytes) -> bytes:
131
+ """Open a sealed box with the recipient private key. Raises on wrong key / tampering."""
132
+ _require()
133
+ return SealedBox(PrivateKey(_b64d(priv_b64))).decrypt(ciphertext)