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.
- hmctl/__init__.py +7 -0
- hmctl/cli.py +947 -0
- hypermind/__init__.py +208 -0
- hypermind/_deprecations.py +108 -0
- hypermind/agent.py +2896 -0
- hypermind/agent_card.py +127 -0
- hypermind/api/__init__.py +32 -0
- hypermind/api/app.py +686 -0
- hypermind/capability.py +923 -0
- hypermind/consult.py +493 -0
- hypermind/consult_bias.py +112 -0
- hypermind/contract_net.py +219 -0
- hypermind/crypto/__init__.py +21 -0
- hypermind/crypto/cose_encrypt.py +126 -0
- hypermind/crypto/dkg.py +246 -0
- hypermind/crypto/domain.py +59 -0
- hypermind/crypto/frost.py +771 -0
- hypermind/crypto/hashing.py +38 -0
- hypermind/crypto/hlc.py +163 -0
- hypermind/crypto/hpke.py +277 -0
- hypermind/crypto/kms.py +196 -0
- hypermind/crypto/kms_backends/__init__.py +56 -0
- hypermind/crypto/kms_backends/aws.py +128 -0
- hypermind/crypto/kms_backends/azure.py +114 -0
- hypermind/crypto/kms_backends/gcp.py +97 -0
- hypermind/crypto/kms_backends/vault.py +120 -0
- hypermind/crypto/namespace_root.py +204 -0
- hypermind/crypto/pq.py +152 -0
- hypermind/crypto/room_epoch.py +97 -0
- hypermind/crypto/signing.py +245 -0
- hypermind/deliberation.py +355 -0
- hypermind/did_web.py +256 -0
- hypermind/dispute.py +858 -0
- hypermind/errors.py +218 -0
- hypermind/eval/__init__.py +49 -0
- hypermind/eval/calibration.py +307 -0
- hypermind/eval/comparison.py +251 -0
- hypermind/eval/replay.py +202 -0
- hypermind/eval/swarm_iq.py +718 -0
- hypermind/knowledge/__init__.py +17 -0
- hypermind/knowledge/citation_graph.py +141 -0
- hypermind/knowledge/correlated_agreement.py +167 -0
- hypermind/knowledge/embedding_registry.py +115 -0
- hypermind/knowledge/kernel_store.py +197 -0
- hypermind/knowledge/rekor.py +173 -0
- hypermind/knowledge/reputation.py +364 -0
- hypermind/knowledge/swarm_memory.py +523 -0
- hypermind/knowledge/transparency.py +409 -0
- hypermind/mcp/__init__.py +60 -0
- hypermind/mcp/bridge.py +151 -0
- hypermind/mcp/client.py +142 -0
- hypermind/mcp/provenance.py +214 -0
- hypermind/mcp/relata_tools.py +152 -0
- hypermind/mcp/server.py +248 -0
- hypermind/mcp/transport.py +206 -0
- hypermind/mind/__init__.py +150 -0
- hypermind/mind/active.py +234 -0
- hypermind/mind/belief.py +369 -0
- hypermind/mind/deliberator.py +625 -0
- hypermind/mind/diversity.py +144 -0
- hypermind/mind/evolve.py +184 -0
- hypermind/mind/federation.py +154 -0
- hypermind/mind/goals.py +117 -0
- hypermind/mind/integration.py +190 -0
- hypermind/mind/mediator.py +74 -0
- hypermind/mind/memory_store.py +129 -0
- hypermind/mind/policy.py +307 -0
- hypermind/mind/records.py +260 -0
- hypermind/mind/roles.py +190 -0
- hypermind/mind/sybil_guard.py +41 -0
- hypermind/mind/world_model.py +166 -0
- hypermind/namespace.py +515 -0
- hypermind/namespace_policy.py +254 -0
- hypermind/observability/__init__.py +25 -0
- hypermind/observability/asgi.py +214 -0
- hypermind/observability/cost.py +277 -0
- hypermind/observability/otel_export.py +200 -0
- hypermind/observability/recorder.py +344 -0
- hypermind/observability/swarm_trace.py +438 -0
- hypermind/pin_policy.py +116 -0
- hypermind/py.typed +0 -0
- hypermind/responders/__init__.py +87 -0
- hypermind/responders/anthropic.py +159 -0
- hypermind/responders/base.py +162 -0
- hypermind/responders/openai.py +199 -0
- hypermind/responders/router.py +254 -0
- hypermind/responders/structured.py +287 -0
- hypermind/responders/tools.py +444 -0
- hypermind/revocation.py +270 -0
- hypermind/rule_ids.py +135 -0
- hypermind/schemas/encrypted_statement.cddl +28 -0
- hypermind/schemas/namespace_accept.cddl +20 -0
- hypermind/schemas/namespace_create.cddl +25 -0
- hypermind/schemas/namespace_founding_attest.cddl +30 -0
- hypermind/schemas/namespace_invite.cddl +22 -0
- hypermind/schemas/wire-v0.1.cddl +73 -0
- hypermind/simlab/__init__.py +15 -0
- hypermind/simlab/_persona_registry.py +93 -0
- hypermind/simlab/_scenario_impl.py +2778 -0
- hypermind/simlab/app.py +2538 -0
- hypermind/simlab/backends/__init__.py +27 -0
- hypermind/simlab/backends/anchor.py +88 -0
- hypermind/simlab/backends/local.py +32 -0
- hypermind/simlab/backends/server.py +79 -0
- hypermind/simlab/cli_command.py +108 -0
- hypermind/simlab/config.py +106 -0
- hypermind/simlab/deployment.py +26 -0
- hypermind/simlab/knowledge.py +716 -0
- hypermind/simlab/learning.py +295 -0
- hypermind/simlab/modes/__init__.py +115 -0
- hypermind/simlab/modes/_eval_real_llm.py +373 -0
- hypermind/simlab/modes/aar.py +611 -0
- hypermind/simlab/modes/backtest.py +610 -0
- hypermind/simlab/modes/binary_forecast.py +69 -0
- hypermind/simlab/modes/conformance.py +1869 -0
- hypermind/simlab/modes/evaluation.py +2271 -0
- hypermind/simlab/modes/governance.py +648 -0
- hypermind/simlab/modes/redteam.py +534 -0
- hypermind/simlab/modes/tabletop.py +797 -0
- hypermind/simlab/modes/tournament.py +448 -0
- hypermind/simlab/modes/whatif.py +523 -0
- hypermind/simlab/namespace.py +125 -0
- hypermind/simlab/personas.py +477 -0
- hypermind/simlab/prompt_generator.py +172 -0
- hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
- hypermind/simlab/question_sets/governance_q5.json +67 -0
- hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
- hypermind/simlab/question_sets/whatif_q3.json +38 -0
- hypermind/simlab/registry.py +325 -0
- hypermind/simlab/runner.py +239 -0
- hypermind/simlab/scenario.py +147 -0
- hypermind/simlab/swarms.py +180 -0
- hypermind/simlab/tool_handlers/__init__.py +4 -0
- hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
- hypermind/simlab/tool_handlers/brave.py +46 -0
- hypermind/simlab/tool_handlers/newsapi.py +50 -0
- hypermind/simlab/tool_handlers/openweather.py +46 -0
- hypermind/simlab/tool_handlers/pinecone.py +52 -0
- hypermind/simlab/tool_handlers/qdrant.py +57 -0
- hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
- hypermind/simlab/tool_handlers/relata_recall.py +61 -0
- hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
- hypermind/simlab/tool_handlers/serper.py +46 -0
- hypermind/simlab/tool_handlers/tavily.py +53 -0
- hypermind/simlab/tool_handlers/weaviate.py +65 -0
- hypermind/simlab/tool_handlers/wikipedia.py +64 -0
- hypermind/simlab/tool_handlers/wolfram.py +48 -0
- hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
- hypermind/simlab/tool_registry.py +568 -0
- hypermind/simlab/topic_adapter.py +338 -0
- hypermind/simlab/topic_questions.py +259 -0
- hypermind/storage/__init__.py +69 -0
- hypermind/storage/backend.py +71 -0
- hypermind/storage/relata.py +245 -0
- hypermind/storage/sqlite.py +258 -0
- hypermind/sync.py +191 -0
- hypermind/tal.py +175 -0
- hypermind/tasks.py +334 -0
- hypermind/testing.py +16 -0
- hypermind/tools/__init__.py +32 -0
- hypermind/tools/registry.py +61 -0
- hypermind/transport/__init__.py +22 -0
- hypermind/transport/anti_entropy.py +708 -0
- hypermind/transport/bus.py +206 -0
- hypermind/transport/knows_delta.py +204 -0
- hypermind/transport/libp2p.py +298 -0
- hypermind/transport/placement.py +58 -0
- hypermind/transport/tcp.py +797 -0
- hypermind/transport/tls_profile.py +417 -0
- hypermind/types.py +59 -0
- hypermind/uncertainty.py +222 -0
- hypermind/wire.py +897 -0
- hypermind/workflow/__init__.py +72 -0
- hypermind/workflow/engine.py +655 -0
- hypermind/workflow/plan.py +182 -0
- hypermind/workflow/repair.py +203 -0
- hypermind-0.11.0.dist-info/METADATA +524 -0
- hypermind-0.11.0.dist-info/RECORD +181 -0
- hypermind-0.11.0.dist-info/WHEEL +4 -0
- hypermind-0.11.0.dist-info/entry_points.txt +2 -0
- 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)
|