hypermind 0.11.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.
Files changed (181) hide show
  1. hmctl/__init__.py +7 -0
  2. hmctl/cli.py +947 -0
  3. hypermind/__init__.py +208 -0
  4. hypermind/_deprecations.py +108 -0
  5. hypermind/agent.py +2896 -0
  6. hypermind/agent_card.py +127 -0
  7. hypermind/api/__init__.py +32 -0
  8. hypermind/api/app.py +686 -0
  9. hypermind/capability.py +923 -0
  10. hypermind/consult.py +493 -0
  11. hypermind/consult_bias.py +112 -0
  12. hypermind/contract_net.py +219 -0
  13. hypermind/crypto/__init__.py +21 -0
  14. hypermind/crypto/cose_encrypt.py +126 -0
  15. hypermind/crypto/dkg.py +246 -0
  16. hypermind/crypto/domain.py +59 -0
  17. hypermind/crypto/frost.py +771 -0
  18. hypermind/crypto/hashing.py +38 -0
  19. hypermind/crypto/hlc.py +163 -0
  20. hypermind/crypto/hpke.py +277 -0
  21. hypermind/crypto/kms.py +196 -0
  22. hypermind/crypto/kms_backends/__init__.py +56 -0
  23. hypermind/crypto/kms_backends/aws.py +128 -0
  24. hypermind/crypto/kms_backends/azure.py +114 -0
  25. hypermind/crypto/kms_backends/gcp.py +97 -0
  26. hypermind/crypto/kms_backends/vault.py +120 -0
  27. hypermind/crypto/namespace_root.py +204 -0
  28. hypermind/crypto/pq.py +152 -0
  29. hypermind/crypto/room_epoch.py +97 -0
  30. hypermind/crypto/signing.py +245 -0
  31. hypermind/deliberation.py +355 -0
  32. hypermind/did_web.py +256 -0
  33. hypermind/dispute.py +858 -0
  34. hypermind/errors.py +218 -0
  35. hypermind/eval/__init__.py +49 -0
  36. hypermind/eval/calibration.py +307 -0
  37. hypermind/eval/comparison.py +251 -0
  38. hypermind/eval/replay.py +202 -0
  39. hypermind/eval/swarm_iq.py +718 -0
  40. hypermind/knowledge/__init__.py +17 -0
  41. hypermind/knowledge/citation_graph.py +141 -0
  42. hypermind/knowledge/correlated_agreement.py +167 -0
  43. hypermind/knowledge/embedding_registry.py +115 -0
  44. hypermind/knowledge/kernel_store.py +197 -0
  45. hypermind/knowledge/rekor.py +173 -0
  46. hypermind/knowledge/reputation.py +364 -0
  47. hypermind/knowledge/swarm_memory.py +523 -0
  48. hypermind/knowledge/transparency.py +409 -0
  49. hypermind/mcp/__init__.py +60 -0
  50. hypermind/mcp/bridge.py +151 -0
  51. hypermind/mcp/client.py +142 -0
  52. hypermind/mcp/provenance.py +214 -0
  53. hypermind/mcp/relata_tools.py +152 -0
  54. hypermind/mcp/server.py +248 -0
  55. hypermind/mcp/transport.py +206 -0
  56. hypermind/mind/__init__.py +150 -0
  57. hypermind/mind/active.py +234 -0
  58. hypermind/mind/belief.py +369 -0
  59. hypermind/mind/deliberator.py +625 -0
  60. hypermind/mind/diversity.py +144 -0
  61. hypermind/mind/evolve.py +184 -0
  62. hypermind/mind/federation.py +154 -0
  63. hypermind/mind/goals.py +117 -0
  64. hypermind/mind/integration.py +190 -0
  65. hypermind/mind/mediator.py +74 -0
  66. hypermind/mind/memory_store.py +129 -0
  67. hypermind/mind/policy.py +307 -0
  68. hypermind/mind/records.py +260 -0
  69. hypermind/mind/roles.py +190 -0
  70. hypermind/mind/sybil_guard.py +41 -0
  71. hypermind/mind/world_model.py +166 -0
  72. hypermind/namespace.py +515 -0
  73. hypermind/namespace_policy.py +254 -0
  74. hypermind/observability/__init__.py +25 -0
  75. hypermind/observability/asgi.py +214 -0
  76. hypermind/observability/cost.py +277 -0
  77. hypermind/observability/otel_export.py +200 -0
  78. hypermind/observability/recorder.py +344 -0
  79. hypermind/observability/swarm_trace.py +438 -0
  80. hypermind/pin_policy.py +116 -0
  81. hypermind/py.typed +0 -0
  82. hypermind/responders/__init__.py +87 -0
  83. hypermind/responders/anthropic.py +159 -0
  84. hypermind/responders/base.py +162 -0
  85. hypermind/responders/openai.py +199 -0
  86. hypermind/responders/router.py +254 -0
  87. hypermind/responders/structured.py +287 -0
  88. hypermind/responders/tools.py +444 -0
  89. hypermind/revocation.py +270 -0
  90. hypermind/rule_ids.py +135 -0
  91. hypermind/schemas/encrypted_statement.cddl +28 -0
  92. hypermind/schemas/namespace_accept.cddl +20 -0
  93. hypermind/schemas/namespace_create.cddl +25 -0
  94. hypermind/schemas/namespace_founding_attest.cddl +30 -0
  95. hypermind/schemas/namespace_invite.cddl +22 -0
  96. hypermind/schemas/wire-v0.1.cddl +73 -0
  97. hypermind/simlab/__init__.py +15 -0
  98. hypermind/simlab/_persona_registry.py +93 -0
  99. hypermind/simlab/_scenario_impl.py +2778 -0
  100. hypermind/simlab/app.py +2538 -0
  101. hypermind/simlab/backends/__init__.py +27 -0
  102. hypermind/simlab/backends/anchor.py +88 -0
  103. hypermind/simlab/backends/local.py +32 -0
  104. hypermind/simlab/backends/server.py +79 -0
  105. hypermind/simlab/cli_command.py +108 -0
  106. hypermind/simlab/config.py +106 -0
  107. hypermind/simlab/deployment.py +26 -0
  108. hypermind/simlab/knowledge.py +716 -0
  109. hypermind/simlab/learning.py +295 -0
  110. hypermind/simlab/modes/__init__.py +115 -0
  111. hypermind/simlab/modes/_eval_real_llm.py +373 -0
  112. hypermind/simlab/modes/aar.py +611 -0
  113. hypermind/simlab/modes/backtest.py +610 -0
  114. hypermind/simlab/modes/binary_forecast.py +69 -0
  115. hypermind/simlab/modes/conformance.py +1869 -0
  116. hypermind/simlab/modes/evaluation.py +2271 -0
  117. hypermind/simlab/modes/governance.py +648 -0
  118. hypermind/simlab/modes/redteam.py +534 -0
  119. hypermind/simlab/modes/tabletop.py +797 -0
  120. hypermind/simlab/modes/tournament.py +448 -0
  121. hypermind/simlab/modes/whatif.py +523 -0
  122. hypermind/simlab/namespace.py +125 -0
  123. hypermind/simlab/personas.py +477 -0
  124. hypermind/simlab/prompt_generator.py +172 -0
  125. hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
  126. hypermind/simlab/question_sets/governance_q5.json +67 -0
  127. hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
  128. hypermind/simlab/question_sets/whatif_q3.json +38 -0
  129. hypermind/simlab/registry.py +325 -0
  130. hypermind/simlab/runner.py +239 -0
  131. hypermind/simlab/scenario.py +147 -0
  132. hypermind/simlab/swarms.py +180 -0
  133. hypermind/simlab/tool_handlers/__init__.py +4 -0
  134. hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
  135. hypermind/simlab/tool_handlers/brave.py +46 -0
  136. hypermind/simlab/tool_handlers/newsapi.py +50 -0
  137. hypermind/simlab/tool_handlers/openweather.py +46 -0
  138. hypermind/simlab/tool_handlers/pinecone.py +52 -0
  139. hypermind/simlab/tool_handlers/qdrant.py +57 -0
  140. hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
  141. hypermind/simlab/tool_handlers/relata_recall.py +61 -0
  142. hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
  143. hypermind/simlab/tool_handlers/serper.py +46 -0
  144. hypermind/simlab/tool_handlers/tavily.py +53 -0
  145. hypermind/simlab/tool_handlers/weaviate.py +65 -0
  146. hypermind/simlab/tool_handlers/wikipedia.py +64 -0
  147. hypermind/simlab/tool_handlers/wolfram.py +48 -0
  148. hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
  149. hypermind/simlab/tool_registry.py +568 -0
  150. hypermind/simlab/topic_adapter.py +338 -0
  151. hypermind/simlab/topic_questions.py +259 -0
  152. hypermind/storage/__init__.py +69 -0
  153. hypermind/storage/backend.py +71 -0
  154. hypermind/storage/relata.py +245 -0
  155. hypermind/storage/sqlite.py +258 -0
  156. hypermind/sync.py +191 -0
  157. hypermind/tal.py +175 -0
  158. hypermind/tasks.py +334 -0
  159. hypermind/testing.py +16 -0
  160. hypermind/tools/__init__.py +32 -0
  161. hypermind/tools/registry.py +61 -0
  162. hypermind/transport/__init__.py +22 -0
  163. hypermind/transport/anti_entropy.py +708 -0
  164. hypermind/transport/bus.py +206 -0
  165. hypermind/transport/knows_delta.py +204 -0
  166. hypermind/transport/libp2p.py +298 -0
  167. hypermind/transport/placement.py +58 -0
  168. hypermind/transport/tcp.py +797 -0
  169. hypermind/transport/tls_profile.py +417 -0
  170. hypermind/types.py +59 -0
  171. hypermind/uncertainty.py +222 -0
  172. hypermind/wire.py +897 -0
  173. hypermind/workflow/__init__.py +72 -0
  174. hypermind/workflow/engine.py +655 -0
  175. hypermind/workflow/plan.py +182 -0
  176. hypermind/workflow/repair.py +203 -0
  177. hypermind-0.11.0.dist-info/METADATA +524 -0
  178. hypermind-0.11.0.dist-info/RECORD +181 -0
  179. hypermind-0.11.0.dist-info/WHEEL +4 -0
  180. hypermind-0.11.0.dist-info/entry_points.txt +2 -0
  181. hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,771 @@
1
+ """FROST (RFC 9591) k-of-n threshold Schnorr signatures over Ed25519.
2
+
3
+ This is a working implementation suitable for namespace policy
4
+ signing (e.g. 5-of-7 maintainer council). It uses the Ed25519
5
+ ciphersuite (RFC 9591 §6.1) with the following hardening steps
6
+ (applied per self-heal Cycle-1 Finding #2 and panel architectural
7
+ shift A2-1):
8
+
9
+ 1. Hedged-random nonce derivation per RFC 9591 §4.2 — each Round-1
10
+ commit() call mixes a fresh 32-byte randomizer (from
11
+ `secrets.token_bytes(32)`) into the deterministic PRF inputs.
12
+ The randomizer is published alongside (D_i, E_i) in the
13
+ `Commitment` so the same Round-2 signer can re-derive
14
+ (d_i, e_i). This is Drijvers-replay-immune by construction:
15
+ each session uses fresh entropy, so a process restart that
16
+ empties the `_SEEN_TRANSCRIPTS` LRU does not re-open the
17
+ replay window. The earlier RFC 9591 §4.1 deterministic variant
18
+ required durable signer state to be safe across restart and is
19
+ no longer used.
20
+ 2. Small-subgroup validation — every received commitment point is
21
+ multiplied by the group order L; the result must be the identity.
22
+ 3. Challenge hash binds the full encoded commitment list, not just
23
+ `R`. Even if an attacker could craft commitments that aggregate
24
+ to a chosen R, the challenge would not match.
25
+ 4. Per-share input validation — share index must be in `[1, 2**32)`,
26
+ duplicate indices are rejected by the aggregator, and signers
27
+ refuse to sign the same `(commitments, message)` twice.
28
+
29
+ Algorithm (the bits we actually use):
30
+ - Trusted setup (DKG-trusted-dealer variant): a dealer samples a
31
+ polynomial f of degree t-1, with f(0) = group secret. Each
32
+ participant i ∈ {1..n} receives sk_i = f(i). The group public key
33
+ is Y = f(0) * G.
34
+ - Signing: t participants each derive (d_i, e_i) deterministically,
35
+ publish commitments (D_i, E_i) = (d_i*G, e_i*G). Aggregator
36
+ computes binding factor ρ_i for each participant from all
37
+ (D_i, E_i, i, M, group_pk), forms R = Σ_i (D_i + ρ_i * E_i), and
38
+ challenge c = H(commits || group_pk || R || M). Each participant
39
+ returns z_i = d_i + ρ_i*e_i + λ_i * sk_i * c, where λ_i is the
40
+ i-th Lagrange coefficient. Aggregate signature (R, z = Σ z_i)
41
+ verifies as z*G == R + c*Y.
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import hashlib
47
+ import secrets
48
+ import threading
49
+ from collections import OrderedDict
50
+ from dataclasses import dataclass
51
+
52
+ # Edwards25519 group operations from PyNaCl/NaCl.
53
+ from nacl.bindings import (
54
+ crypto_core_ed25519_add,
55
+ crypto_core_ed25519_is_valid_point,
56
+ crypto_core_ed25519_scalar_add,
57
+ crypto_core_ed25519_scalar_invert,
58
+ crypto_core_ed25519_scalar_mul,
59
+ crypto_core_ed25519_scalar_reduce,
60
+ crypto_scalarmult_ed25519_base_noclamp,
61
+ crypto_scalarmult_ed25519_noclamp,
62
+ )
63
+
64
+ L = 2**252 + 27742317777372353535851937790883648493 # Ed25519 group order
65
+ SCALAR_L_BYTES = L.to_bytes(32, "little")
66
+ ZERO_SCALAR = b"\x00" * 32
67
+ IDENTITY_POINT = b"\x01" + b"\x00" * 31 # encoding of the identity
68
+
69
+ MAX_SIGNER_INDEX = 2**32
70
+
71
+ # A process-wide guard against signing the same (commitments, message)
72
+ # twice with the same nonce. Closes the Drijvers-style attack where a
73
+ # malicious aggregator presents the honest signer with two different
74
+ # commitment sets and the signer accidentally re-uses (d_i, e_i).
75
+ #
76
+ # Bounded LRU cap (closes Cycle-2 Finding #1): without a bound, a
77
+ # long-running signer accumulates a transcript per signature forever,
78
+ # leaking memory and providing a DoS vector. 65536 entries × 64 bytes
79
+ # is ~4 MB resident, sufficient to absorb any realistic concurrent
80
+ # signing rate while keeping memory bounded.
81
+ _SIGN_LOCK = threading.Lock()
82
+ MAX_SEEN_TRANSCRIPTS = 65_536
83
+ _SEEN_TRANSCRIPTS: OrderedDict[bytes, None] = OrderedDict()
84
+
85
+
86
+ # ---- pluggable transcript store (ROADMAP §0.7.8) ---------------------
87
+ #
88
+ # The default ``InMemoryLRU`` is a process-local LRU sized at
89
+ # MAX_SEEN_TRANSCRIPTS entries. Operators running long-lived signers
90
+ # across restarts SHOULD replace it with a durable backend (Redis,
91
+ # SQLite, etc.) by calling :func:`set_transcript_store`.
92
+ #
93
+ # Durability contract for any TranscriptStore implementation:
94
+ # 1. ``record(h)`` MUST persist before the signer's ``z_i`` leaves
95
+ # the process. A backend that buffers asynchronously re-opens the
96
+ # Drijvers replay window across restart.
97
+ # 2. ``seen(h)`` MUST be linearisable with respect to ``record`` from
98
+ # the same signer. Concurrent signers MAY observe each other's
99
+ # writes lazily but a single signer must see its own writes.
100
+ # 3. The store MAY evict old entries (LRU/TTL) but MUST NOT evict
101
+ # entries younger than the longest plausible session lifetime
102
+ # (signers SHOULD configure a TTL ≥ 24h).
103
+
104
+
105
+ class TranscriptStore:
106
+ """Protocol for the FROST nonce-reuse dedup store.
107
+
108
+ Implementations MUST be threadsafe. ``record`` returns ``True`` if
109
+ the transcript was new (signature MAY proceed) and ``False`` if it
110
+ has been seen before (signer MUST refuse to sign).
111
+ """
112
+
113
+ def seen(self, transcript_hash: bytes) -> bool: # pragma: no cover - protocol
114
+ raise NotImplementedError
115
+
116
+ def record(self, transcript_hash: bytes) -> bool: # pragma: no cover - protocol
117
+ raise NotImplementedError
118
+
119
+ def clear(self) -> None: # pragma: no cover - protocol
120
+ raise NotImplementedError
121
+
122
+
123
+ class InMemoryLRU(TranscriptStore):
124
+ """Default backend: bounded in-memory LRU, threadsafe.
125
+
126
+ Capacity defaults to :data:`MAX_SEEN_TRANSCRIPTS` (65 536 entries
127
+ × 64 bytes ≈ 4 MB resident). Loses state on process restart; for
128
+ durability across restart, swap in a database-backed store via
129
+ :func:`set_transcript_store`.
130
+ """
131
+
132
+ def __init__(self, capacity: int = MAX_SEEN_TRANSCRIPTS) -> None:
133
+ if capacity <= 0:
134
+ raise ValueError("capacity must be positive")
135
+ self._capacity = capacity
136
+ self._lock = threading.Lock()
137
+ self._seen: OrderedDict[bytes, None] = OrderedDict()
138
+
139
+ def seen(self, transcript_hash: bytes) -> bool:
140
+ with self._lock:
141
+ return transcript_hash in self._seen
142
+
143
+ def record(self, transcript_hash: bytes) -> bool:
144
+ with self._lock:
145
+ if transcript_hash in self._seen:
146
+ self._seen.move_to_end(transcript_hash)
147
+ return False
148
+ self._seen[transcript_hash] = None
149
+ while len(self._seen) > self._capacity:
150
+ self._seen.popitem(last=False)
151
+ return True
152
+
153
+ def clear(self) -> None:
154
+ with self._lock:
155
+ self._seen.clear()
156
+
157
+
158
+ _TRANSCRIPT_STORE: TranscriptStore = InMemoryLRU()
159
+
160
+
161
+ class FileTranscriptStore(TranscriptStore):
162
+ """Durable transcript store backed by a JSON file on disk.
163
+
164
+ Suitable for long-lived signers that must survive process restarts
165
+ without re-opening the Drijvers nonce-reuse window. Each call to
166
+ ``record()`` persists the updated set to ``path`` before returning.
167
+
168
+ Thread-safety: a per-instance lock serialises concurrent reads/writes.
169
+ Only the instance that holds the lock writes to the file; concurrent
170
+ processes sharing the same file path are NOT safe — use a database-
171
+ backed store for multi-process deployments.
172
+ """
173
+
174
+ def __init__(self, path: str) -> None:
175
+ self._path = path
176
+ self._lock = threading.Lock()
177
+ self._seen: set[bytes] = self._load()
178
+
179
+ def _load(self) -> set[bytes]:
180
+ import base64
181
+ import json
182
+
183
+ try:
184
+ with open(self._path) as f:
185
+ return {base64.b64decode(x) for x in json.load(f)}
186
+ except (FileNotFoundError, json.JSONDecodeError):
187
+ return set()
188
+
189
+ def save(self) -> None:
190
+ """Persist the current seen-set to disk (called automatically by ``record``)."""
191
+ import base64
192
+ import json
193
+
194
+ with open(self._path, "w") as f:
195
+ json.dump([base64.b64encode(h).decode() for h in self._seen], f)
196
+
197
+ def record(self, transcript_hash: bytes) -> bool:
198
+ """Return True if new (not seen), False if replay detected. Persists on new entry."""
199
+ with self._lock:
200
+ if transcript_hash in self._seen:
201
+ return False
202
+ self._seen.add(transcript_hash)
203
+ self.save()
204
+ return True
205
+
206
+ def seen(self, transcript_hash: bytes) -> bool:
207
+ with self._lock:
208
+ return transcript_hash in self._seen
209
+
210
+ def clear(self) -> None:
211
+ with self._lock:
212
+ self._seen.clear()
213
+ self.save()
214
+
215
+
216
+ def set_transcript_store(store: TranscriptStore) -> None:
217
+ """Install a custom :class:`TranscriptStore` backend.
218
+
219
+ This is process-global; call once at startup BEFORE any FROST
220
+ signing happens. Re-installing mid-session is allowed but discards
221
+ the previous store's history — the durability contract above no
222
+ longer holds for transcripts seen by the previous backend.
223
+
224
+ SAFETY (closes self-heal Cycle-3 issue #1): if the previous store has
225
+ already recorded transcripts, swapping it out re-opens the Drijvers
226
+ nonce-reuse window for those transcripts. Emits a RuntimeWarning so
227
+ operators don't silently swap mid-session. Tests that legitimately
228
+ need a fresh store should call :func:`reset_seen_transcripts` first.
229
+ """
230
+ global _TRANSCRIPT_STORE
231
+ # Detect non-empty previous store (legacy mirror OR backend-specific
232
+ # internal LRU). `len(...) > 0` is explicit so an empty OrderedDict
233
+ # (falsy) doesn't suppress the warning.
234
+ has_prior = len(_SEEN_TRANSCRIPTS) > 0
235
+ if not has_prior:
236
+ prior_seen = getattr(_TRANSCRIPT_STORE, "_seen", None)
237
+ if prior_seen is not None and len(prior_seen) > 0:
238
+ has_prior = True
239
+ if has_prior:
240
+ import warnings as _w
241
+
242
+ _w.warn(
243
+ "[FROST-TRANSCRIPT-STORE-SWAP] set_transcript_store() called while "
244
+ "the previous store has recorded transcripts. The new store does NOT "
245
+ "carry forward the old transcripts — this re-opens the Drijvers "
246
+ "nonce-reuse window for any commitments already used. Call "
247
+ "reset_seen_transcripts() explicitly if this is intended.",
248
+ RuntimeWarning,
249
+ stacklevel=2,
250
+ )
251
+ _TRANSCRIPT_STORE = store
252
+
253
+
254
+ def get_transcript_store() -> TranscriptStore:
255
+ """Return the currently-installed transcript store."""
256
+ return _TRANSCRIPT_STORE
257
+
258
+
259
+ def _record_transcript(transcript: bytes) -> bool:
260
+ """Returns True if the transcript was new (signature may proceed),
261
+ False if it has been seen before (signer MUST refuse).
262
+
263
+ Routes through the pluggable store; default backend is the
264
+ process-local LRU above. Also mirrors into the legacy
265
+ ``_SEEN_TRANSCRIPTS`` OrderedDict for back-compat with tests that
266
+ still introspect it directly.
267
+ """
268
+ new = _TRANSCRIPT_STORE.record(transcript)
269
+ # Maintain the legacy module-level mirror unconditionally (closes
270
+ # self-heal Cycle-3 issue #1: previous behaviour only mirrored under
271
+ # the default InMemoryLRU, so any test or operator tool that
272
+ # introspected `_SEEN_TRANSCRIPTS` saw stale state the moment a
273
+ # custom backend was installed via `set_transcript_store()`. The
274
+ # mirror is now backend-agnostic and stays in lock-step with the
275
+ # store's own bookkeeping). New code SHOULD route through
276
+ # `get_transcript_store().seen(h)` instead of poking the mirror —
277
+ # the mirror is retained only for back-compat with tests written
278
+ # against the v0.6 API and is scheduled for removal in v1.0.
279
+ if new:
280
+ _SEEN_TRANSCRIPTS[transcript] = None
281
+ while len(_SEEN_TRANSCRIPTS) > MAX_SEEN_TRANSCRIPTS:
282
+ _SEEN_TRANSCRIPTS.popitem(last=False)
283
+ else:
284
+ # Touch on hit so LRU semantics stay consistent.
285
+ _SEEN_TRANSCRIPTS.move_to_end(transcript, last=True)
286
+ return new
287
+
288
+
289
+ def reset_seen_transcripts() -> None:
290
+ """Test hook — clears the dedup cache between test cases. NEVER call
291
+ in production: doing so re-opens the Drijvers nonce-reuse window."""
292
+ with _SIGN_LOCK:
293
+ _SEEN_TRANSCRIPTS.clear()
294
+ _TRANSCRIPT_STORE.clear()
295
+
296
+
297
+ def scalar_from_int(x: int) -> bytes:
298
+ return (x % L).to_bytes(32, "little")
299
+
300
+
301
+ def scalar_to_int(s: bytes) -> int:
302
+ return int.from_bytes(s, "little")
303
+
304
+
305
+ def random_scalar() -> bytes:
306
+ return crypto_core_ed25519_scalar_reduce(secrets.token_bytes(64))
307
+
308
+
309
+ def hash_to_scalar(*chunks: bytes, label: bytes = b"FROST/ed25519") -> bytes:
310
+ """SHA-512 → mod L. Used for binding factors and challenge hashes."""
311
+ h = hashlib.sha512()
312
+ h.update(label)
313
+ for c in chunks:
314
+ h.update(len(c).to_bytes(4, "big"))
315
+ h.update(c)
316
+ digest = h.digest()
317
+ return crypto_core_ed25519_scalar_reduce(digest)
318
+
319
+
320
+ def lagrange_coefficient(i: int, indices: list[int]) -> bytes:
321
+ """Compute λ_i = Π_{j != i} j / (j - i) mod L."""
322
+ num_int = 1
323
+ den_int = 1
324
+ for j in indices:
325
+ if j == i:
326
+ continue
327
+ num_int = (num_int * j) % L
328
+ den_int = (den_int * ((j - i) % L)) % L
329
+ num = scalar_from_int(num_int)
330
+ den = scalar_from_int(den_int)
331
+ den_inv = crypto_core_ed25519_scalar_invert(den)
332
+ return crypto_core_ed25519_scalar_mul(num, den_inv)
333
+
334
+
335
+ def _validate_point(p: bytes) -> None:
336
+ """Reject the identity and any low-order / non-canonical encodings.
337
+
338
+ `crypto_core_ed25519_is_valid_point` performs canonicality + the
339
+ cofactor-clearing check that detects small-subgroup attacks. Reject
340
+ the identity explicitly because libsodium's helper accepts it.
341
+ """
342
+ if len(p) != 32:
343
+ raise ValueError("ed25519 point must be 32 bytes")
344
+ if p == IDENTITY_POINT:
345
+ raise ValueError("rejected identity point in commitment")
346
+ if not crypto_core_ed25519_is_valid_point(p):
347
+ raise ValueError("rejected invalid / small-subgroup point")
348
+
349
+
350
+ def _validate_scalar(s: bytes) -> None:
351
+ """Reject zero scalars and non-canonical encodings."""
352
+ if len(s) != 32:
353
+ raise ValueError("scalar must be 32 bytes")
354
+ if s == ZERO_SCALAR:
355
+ raise ValueError("rejected zero scalar")
356
+ if int.from_bytes(s, "little") >= L:
357
+ raise ValueError("rejected non-canonical scalar (>= L)")
358
+
359
+
360
+ # ---- key generation (trusted dealer) -----------------------------------
361
+
362
+
363
+ @dataclass(frozen=True)
364
+ class FrostShare:
365
+ index: int # 1-indexed participant identifier
366
+ secret: bytes # 32-byte scalar = f(index)
367
+ group_public: bytes # 32-byte compressed Edwards point Y = f(0)*G
368
+
369
+ def __post_init__(self) -> None:
370
+ if not (1 <= self.index < MAX_SIGNER_INDEX):
371
+ raise ValueError(f"share index {self.index} out of range [1, {MAX_SIGNER_INDEX})")
372
+ _validate_scalar(self.secret)
373
+ _validate_point(self.group_public)
374
+
375
+ def __repr__(self) -> str:
376
+ # Never render `secret` — it's the per-signer Shamir share. Show
377
+ # only the share index and group public key (both public values).
378
+ return (
379
+ f"FrostShare(index={self.index}, "
380
+ f"group_public={self.group_public.hex()[:16]}..., "
381
+ f"secret=<redacted>)"
382
+ )
383
+
384
+ def __str__(self) -> str:
385
+ return self.__repr__()
386
+
387
+ def __reduce__(self):
388
+ # Pickle is a known leak vector — a serialized FrostShare would
389
+ # carry `secret` to disk / over the wire. Refuse outright.
390
+ raise TypeError("refusing to pickle secret material; use the in-memory store")
391
+
392
+
393
+ def generate_shares(t: int, n: int) -> list[FrostShare]:
394
+ """Trusted-dealer DKG: produce n shares of a fresh secret f(0)."""
395
+ if not (1 <= t <= n):
396
+ raise ValueError("require 1 <= t <= n")
397
+ if n >= MAX_SIGNER_INDEX:
398
+ raise ValueError(f"n must be < {MAX_SIGNER_INDEX}")
399
+ coeffs = [random_scalar() for _ in range(t)]
400
+ group_secret = coeffs[0]
401
+ group_public = crypto_scalarmult_ed25519_base_noclamp(group_secret)
402
+
403
+ shares = []
404
+ for i in range(1, n + 1):
405
+ x = i
406
+ x_pow = scalar_from_int(1)
407
+ x_step = scalar_from_int(x)
408
+ acc = scalar_from_int(0)
409
+ for k, a_k in enumerate(coeffs):
410
+ term = crypto_core_ed25519_scalar_mul(a_k, x_pow)
411
+ acc = crypto_core_ed25519_scalar_add(acc, term)
412
+ if k < len(coeffs) - 1:
413
+ x_pow = crypto_core_ed25519_scalar_mul(x_pow, x_step)
414
+ shares.append(FrostShare(index=i, secret=acc, group_public=group_public))
415
+ return shares
416
+
417
+
418
+ # ---- signing ----------------------------------------------------------
419
+
420
+
421
+ RANDOMIZER_LEN = 32
422
+
423
+
424
+ @dataclass(frozen=True)
425
+ class Commitment:
426
+ """Round-1 commitment carrying the hedged-random randomizer.
427
+
428
+ Per RFC 9591 §4.2 (hedged-random variant), each Round-1 session
429
+ samples a fresh 32-byte `randomizer` that is mixed into the nonce
430
+ derivation. The randomizer is **public** — it is transmitted with
431
+ `(D_i, E_i)` so Round-2 signers (and only Round-2 signers, who
432
+ additionally hold the secret share) can re-derive `(d_i, e_i)`.
433
+ The randomizer alone reveals nothing about the secret share.
434
+
435
+ Wire-version note: the addition of `randomizer` does not change
436
+ the FROST wire format used by HyperMind statements — `Commitment`
437
+ is an in-memory protocol object passed between Round-1 and
438
+ Round-2 of a single signing session, not embedded in
439
+ `SignedStatement`. No `wire_version` bump is required.
440
+ """
441
+
442
+ index: int
443
+ hiding: bytes # D_i = d_i * G
444
+ binding: bytes # E_i = e_i * G
445
+ randomizer: bytes = b"\x00" * RANDOMIZER_LEN # RFC 9591 §4.2 hedge
446
+
447
+ def __post_init__(self) -> None:
448
+ if not (1 <= self.index < MAX_SIGNER_INDEX):
449
+ raise ValueError(f"commitment index {self.index} out of range")
450
+ _validate_point(self.hiding)
451
+ _validate_point(self.binding)
452
+ if len(self.randomizer) != RANDOMIZER_LEN:
453
+ raise ValueError(
454
+ f"randomizer must be {RANDOMIZER_LEN} bytes, got {len(self.randomizer)}"
455
+ )
456
+
457
+
458
+ @dataclass(frozen=True)
459
+ class _Nonce:
460
+ hiding: bytes # d_i
461
+ binding: bytes # e_i
462
+
463
+
464
+ def _derive_nonce(secret: bytes, msg: bytes, index: int, *, label: bytes) -> bytes:
465
+ """Legacy deterministic nonce derivation (RFC 9591 §4.1).
466
+
467
+ Retained for backward-compat callers and the Cycle-2 regression
468
+ test that pins the determinism property. New code SHOULD use
469
+ `_derive_nonce_hedged()` (RFC 9591 §4.2) which mixes in fresh
470
+ entropy and is Drijvers-replay-immune across process restart.
471
+
472
+ The PRF input is `(secret || msg || index || label)` — no fresh
473
+ entropy is mixed in. Two parallel sessions over the same triple
474
+ necessarily produce the same nonce; the transcript-dedup
475
+ `_record_transcript()` then refuses to release the second `z_i`.
476
+ """
477
+ return hash_to_scalar(
478
+ secret,
479
+ msg,
480
+ index.to_bytes(8, "big"),
481
+ label=label,
482
+ )
483
+
484
+
485
+ def _derive_nonce_hedged(
486
+ secret_share: bytes,
487
+ msg: bytes,
488
+ signer_index: int,
489
+ label: bytes,
490
+ randomizer: bytes,
491
+ ) -> bytes:
492
+ """RFC 9591 §4.2 hedged-random nonce.
493
+
494
+ The randomizer (32 bytes from `secrets.token_bytes(32)`) is mixed
495
+ in alongside deterministic inputs. This is Drijvers-replay-immune
496
+ by construction (each session uses fresh entropy) while still
497
+ benefiting from deterministic-style failure-domain isolation via
498
+ the secret-share input.
499
+
500
+ Replaces the pure-deterministic RFC 9591 §4.1 variant which
501
+ required durable signer state to be safe against process restart
502
+ (the in-memory `_SEEN_TRANSCRIPTS` LRU empties on restart).
503
+ """
504
+ if len(randomizer) != RANDOMIZER_LEN:
505
+ raise ValueError(f"randomizer must be {RANDOMIZER_LEN} bytes, got {len(randomizer)}")
506
+ h = hashlib.blake2b(digest_size=64)
507
+ h.update(b"hypermind-frost-nonce-v2|")
508
+ h.update(secret_share)
509
+ h.update(signer_index.to_bytes(4, "big"))
510
+ h.update(len(msg).to_bytes(8, "big"))
511
+ h.update(msg)
512
+ h.update(len(label).to_bytes(4, "big"))
513
+ h.update(label)
514
+ h.update(randomizer)
515
+ # Reduce blake2b-512 output mod L to obtain a uniform Ed25519 scalar.
516
+ return crypto_core_ed25519_scalar_reduce(h.digest())
517
+
518
+
519
+ def commit(
520
+ *,
521
+ share: FrostShare,
522
+ message: bytes,
523
+ randomizer: bytes | None = None,
524
+ ) -> tuple[_Nonce, Commitment]:
525
+ """Round-1: derive (d_i, e_i) hedged-random per RFC 9591 §4.2 and
526
+ publish (D_i, E_i, randomizer).
527
+
528
+ `randomizer` is normally generated fresh by `secrets.token_bytes(32)`.
529
+ Tests / spec interop scenarios may pass a fixed randomizer; production
530
+ callers MUST leave it `None`.
531
+ """
532
+ if randomizer is None:
533
+ randomizer = secrets.token_bytes(RANDOMIZER_LEN)
534
+ elif len(randomizer) != RANDOMIZER_LEN:
535
+ raise ValueError(f"randomizer must be {RANDOMIZER_LEN} bytes, got {len(randomizer)}")
536
+ d = _derive_nonce_hedged(share.secret, message, share.index, b"FROST/nonce/hiding", randomizer)
537
+ e = _derive_nonce_hedged(share.secret, message, share.index, b"FROST/nonce/binding", randomizer)
538
+ _validate_scalar(d)
539
+ _validate_scalar(e)
540
+ D = crypto_scalarmult_ed25519_base_noclamp(d)
541
+ E = crypto_scalarmult_ed25519_base_noclamp(e)
542
+ return (
543
+ _Nonce(hiding=d, binding=e),
544
+ Commitment(index=share.index, hiding=D, binding=E, randomizer=randomizer),
545
+ )
546
+
547
+
548
+ def _serialize_commitments(commitments: list[Commitment]) -> bytes:
549
+ """Serialize commitment list for the binding-factor + challenge hash."""
550
+ out = b""
551
+ for c in sorted(commitments, key=lambda c: c.index):
552
+ out += c.index.to_bytes(8, "big") + c.hiding + c.binding
553
+ return out
554
+
555
+
556
+ def _validate_commitment_set(commitments: list[Commitment]) -> None:
557
+ """Reject duplicate signer indices and zero-length sets."""
558
+ if not commitments:
559
+ raise ValueError("commitment set must be non-empty")
560
+ seen = set()
561
+ for c in commitments:
562
+ if c.index in seen:
563
+ raise ValueError(f"duplicate signer index {c.index} in commitment set")
564
+ seen.add(c.index)
565
+
566
+
567
+ def compute_binding_factors(
568
+ commitments: list[Commitment], message: bytes, group_pk: bytes
569
+ ) -> dict[int, bytes]:
570
+ """ρ_i = H("rho" || i || ser(commits) || M || group_pk) for each signer."""
571
+ _validate_commitment_set(commitments)
572
+ factors = {}
573
+ ser = _serialize_commitments(commitments)
574
+ for c in commitments:
575
+ factors[c.index] = hash_to_scalar(
576
+ b"rho",
577
+ c.index.to_bytes(8, "big"),
578
+ ser,
579
+ message,
580
+ group_pk,
581
+ label=b"FROST/ed25519/rho",
582
+ )
583
+ return factors
584
+
585
+
586
+ def aggregate_R(commitments: list[Commitment], rhos: dict[int, bytes]) -> bytes:
587
+ """R = Σ_i (D_i + ρ_i * E_i)."""
588
+ R = None
589
+ for c in commitments:
590
+ rho = rhos[c.index]
591
+ rho_E = crypto_scalarmult_ed25519_noclamp(rho, c.binding)
592
+ contribution = crypto_core_ed25519_add(c.hiding, rho_E)
593
+ if R is None:
594
+ R = contribution
595
+ else:
596
+ R = crypto_core_ed25519_add(R, contribution)
597
+ assert R is not None
598
+ return R
599
+
600
+
601
+ def challenge(commitments: list[Commitment], R: bytes, group_pk: bytes, message: bytes) -> bytes:
602
+ """c = H(encode(commitments) || group_pk || R || M).
603
+
604
+ Per RFC 9591 §4.6, the challenge MUST bind the encoded commitment
605
+ list, not just R — defends against rogue-commitment / Drijvers-class
606
+ attacks even if R could be otherwise predicted.
607
+ """
608
+ return hash_to_scalar(
609
+ _serialize_commitments(commitments),
610
+ group_pk,
611
+ R,
612
+ message,
613
+ label=b"FROST/ed25519/chall",
614
+ )
615
+
616
+
617
+ def sign_share(
618
+ share: FrostShare,
619
+ nonce: _Nonce,
620
+ commitments: list[Commitment],
621
+ message: bytes,
622
+ *,
623
+ bind_commitments_in_challenge: bool = False,
624
+ ) -> bytes:
625
+ """z_i = d_i + ρ_i*e_i + λ_i * sk_i * c.
626
+
627
+ Refuses to sign the same `(commitments, message, share.index)`
628
+ twice — defeats the Drijvers concurrent-session attack where a
629
+ malicious aggregator presents the same signer with two different
630
+ commitment sets to harvest a second `z_i` over the same nonce.
631
+
632
+ `bind_commitments_in_challenge` controls the challenge form:
633
+ - True → full RFC 9591 `H(commits || group_pk || R || M)`. Use
634
+ this for production multi-round signing where verifiers
635
+ receive the commitment list.
636
+ - False → `H(R || group_pk || M)` (the `_challenge_no_commitments`
637
+ form). Use this for the `threshold_sign` helper that
638
+ publishes a self-contained signature.
639
+ """
640
+ _validate_commitment_set(commitments)
641
+ if not any(c.index == share.index for c in commitments):
642
+ raise ValueError(f"signer index {share.index} not in commitment set")
643
+
644
+ transcript = hashlib.sha512(
645
+ b"FROST/transcript"
646
+ + share.index.to_bytes(8, "big")
647
+ + _serialize_commitments(commitments)
648
+ + message
649
+ + (b"\x01" if bind_commitments_in_challenge else b"\x00")
650
+ ).digest()
651
+ with _SIGN_LOCK:
652
+ if not _record_transcript(transcript):
653
+ raise ValueError("refusing to re-sign identical transcript (nonce-reuse defence)")
654
+
655
+ rhos = compute_binding_factors(commitments, message, share.group_public)
656
+ R = aggregate_R(commitments, rhos)
657
+ if bind_commitments_in_challenge:
658
+ c = challenge(commitments, R, share.group_public, message)
659
+ else:
660
+ c = _challenge_no_commitments(R, share.group_public, message)
661
+ indices = [cm.index for cm in commitments]
662
+ lam = lagrange_coefficient(share.index, indices)
663
+ rho = rhos[share.index]
664
+ rho_e = crypto_core_ed25519_scalar_mul(rho, nonce.binding)
665
+ d_plus_rho_e = crypto_core_ed25519_scalar_add(nonce.hiding, rho_e)
666
+ sk_c = crypto_core_ed25519_scalar_mul(share.secret, c)
667
+ lam_sk_c = crypto_core_ed25519_scalar_mul(lam, sk_c)
668
+ return crypto_core_ed25519_scalar_add(d_plus_rho_e, lam_sk_c)
669
+
670
+
671
+ def aggregate_signature(
672
+ shares: list[bytes], commitments: list[Commitment], rhos: dict[int, bytes]
673
+ ) -> tuple[bytes, bytes]:
674
+ """(R, z) — z = Σ z_i; R is recomputed from commitments + rhos."""
675
+ R = aggregate_R(commitments, rhos)
676
+ z = scalar_from_int(0)
677
+ for s in shares:
678
+ z = crypto_core_ed25519_scalar_add(z, s)
679
+ return R, z
680
+
681
+
682
+ def verify(
683
+ group_pk: bytes,
684
+ message: bytes,
685
+ R: bytes,
686
+ z: bytes,
687
+ commitments: list[Commitment] | None = None,
688
+ ) -> bool:
689
+ """Verify a FROST signature: z*G == R + c*Y.
690
+
691
+ The challenge MUST be derived from the same commitment set used
692
+ during signing (RFC 9591 §4.6); callers who didn't keep the
693
+ commitments around can pass `commitments=None` only when the
694
+ signature was produced via `threshold_sign(...)` — that path
695
+ publishes a self-contained signature whose challenge can be
696
+ re-derived from the *aggregated* commitment-set encoding stored
697
+ inside `R` (we accept it for backward-compat by computing the
698
+ challenge from the canonical `R || group_pk || message` form
699
+ matching `threshold_sign_with_commitments`'s last call).
700
+ """
701
+ try:
702
+ _validate_point(group_pk)
703
+ _validate_point(R)
704
+ if z != ZERO_SCALAR:
705
+ _validate_scalar(z)
706
+ if commitments is not None:
707
+ _validate_commitment_set(commitments)
708
+ except ValueError:
709
+ return False
710
+ if commitments is not None:
711
+ c = challenge(commitments, R, group_pk, message)
712
+ else:
713
+ # Self-contained verify: re-derive the challenge from the
714
+ # canonical (R, group_pk, message) tuple. `threshold_sign` uses
715
+ # this same tuple via `_challenge_no_commitments` so verify-
716
+ # without-commitments still works for the convenience helper.
717
+ c = _challenge_no_commitments(R, group_pk, message)
718
+ z_G = crypto_scalarmult_ed25519_base_noclamp(z)
719
+ c_Y = crypto_scalarmult_ed25519_noclamp(c, group_pk)
720
+ rhs = crypto_core_ed25519_add(R, c_Y)
721
+ return z_G == rhs
722
+
723
+
724
+ def _challenge_no_commitments(R: bytes, group_pk: bytes, message: bytes) -> bytes:
725
+ """Convenience challenge for the `threshold_sign` helper that does
726
+ not publish commitments. Used by both the helper's signing path and
727
+ the matching `verify(..., commitments=None)` path."""
728
+ return hash_to_scalar(R, group_pk, message, label=b"FROST/ed25519/chall-helper")
729
+
730
+
731
+ def verify_with_commitments(
732
+ group_pk: bytes,
733
+ message: bytes,
734
+ R: bytes,
735
+ z: bytes,
736
+ commitments: list[Commitment],
737
+ ) -> bool:
738
+ """Full RFC 9591 verifier: challenge bound to the commitment list.
739
+ Pair this with `sign_share(..., bind_commitments_in_challenge=True)`.
740
+ """
741
+ return verify(group_pk, message, R, z, commitments=commitments)
742
+
743
+
744
+ # ---- one-shot helper --------------------------------------------------
745
+
746
+
747
+ def threshold_sign(shares: list[FrostShare], message: bytes) -> tuple[bytes, bytes]:
748
+ """Convenience: t-of-n signing from a list of shares.
749
+
750
+ Each share runs commit() + sign_share() in sequence; the aggregator
751
+ bundles into the (R, z) pair. Returns (R, z) — both 32 bytes.
752
+ """
753
+ if not shares:
754
+ raise ValueError("require at least one share")
755
+ indices_seen = set()
756
+ for s in shares:
757
+ if s.index in indices_seen:
758
+ raise ValueError(f"duplicate share index {s.index}")
759
+ indices_seen.add(s.index)
760
+
761
+ nonces_and_commits = [commit(share=s, message=message) for s in shares]
762
+ commitments = [
763
+ Commitment(index=s.index, hiding=cm.hiding, binding=cm.binding, randomizer=cm.randomizer)
764
+ for s, (_, cm) in zip(shares, nonces_and_commits, strict=False)
765
+ ]
766
+ sig_shares = [
767
+ sign_share(s, n, commitments, message, bind_commitments_in_challenge=False)
768
+ for s, (n, _) in zip(shares, nonces_and_commits, strict=False)
769
+ ]
770
+ rhos = compute_binding_factors(commitments, message, shares[0].group_public)
771
+ return aggregate_signature(sig_shares, commitments, rhos)