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,204 @@
|
|
|
1
|
+
"""Namespace root key — FROST k-of-n group public key for §24 namespace lifecycle.
|
|
2
|
+
|
|
3
|
+
The namespace root is a FROST (RFC 9591) threshold Schnorr group public
|
|
4
|
+
key produced by a DKG ceremony among the founding cohort. It signs the
|
|
5
|
+
`NAMESPACE_CREATE` and `NAMESPACE_FOUNDING_ATTEST` records, and the
|
|
6
|
+
DID:web rendezvous document (§24.6).
|
|
7
|
+
|
|
8
|
+
This module is a thin wrapper around :mod:`hypermind.crypto.frost` — it
|
|
9
|
+
does not duplicate FROST primitives. ``NamespaceRoot.from_dkg()`` runs
|
|
10
|
+
the trusted-dealer DKG variant and computes a transcript hash that is
|
|
11
|
+
bound into the founding attestation (§24.2).
|
|
12
|
+
|
|
13
|
+
v1.0 hooks:
|
|
14
|
+
- :class:`NamespaceEpoch` carries `epoch_id`, transcript hash, and
|
|
15
|
+
group public key. Root key rotation across transparency-log
|
|
16
|
+
epochs is reserved for v1.0 (spec §24.9 `epoch_rotation_proof`).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import cbor2
|
|
25
|
+
|
|
26
|
+
from hypermind.crypto import frost
|
|
27
|
+
from hypermind.crypto.hashing import blake3_32
|
|
28
|
+
from hypermind.crypto.hlc import HLC
|
|
29
|
+
from hypermind.crypto.signing import Keypair
|
|
30
|
+
|
|
31
|
+
# Default FROST threshold — spec §24.2 "k = 5, n = 7".
|
|
32
|
+
DEFAULT_K = 5
|
|
33
|
+
DEFAULT_N = 7
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _canonical_dkg_transcript(
|
|
37
|
+
*,
|
|
38
|
+
member_kids: list[bytes],
|
|
39
|
+
k: int,
|
|
40
|
+
n: int,
|
|
41
|
+
group_public: bytes,
|
|
42
|
+
share_indices: list[int],
|
|
43
|
+
) -> bytes:
|
|
44
|
+
"""Build the canonical CBOR transcript whose BLAKE3 digest binds the DKG.
|
|
45
|
+
|
|
46
|
+
The transcript records (a) the founding member kids in declaration
|
|
47
|
+
order, (b) the threshold parameters, (c) the resulting group public
|
|
48
|
+
key, and (d) the share-index assignment. This is what the §24.3
|
|
49
|
+
``dkg_transcript_hash`` field commits to.
|
|
50
|
+
"""
|
|
51
|
+
return cbor2.dumps(
|
|
52
|
+
{
|
|
53
|
+
"v": 1,
|
|
54
|
+
"members": member_kids,
|
|
55
|
+
"k": k,
|
|
56
|
+
"n": n,
|
|
57
|
+
"group_public": group_public,
|
|
58
|
+
"share_indices": share_indices,
|
|
59
|
+
},
|
|
60
|
+
canonical=True,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class NamespaceEpoch:
|
|
66
|
+
"""A single epoch of the namespace root key.
|
|
67
|
+
|
|
68
|
+
v0.9.x ships epoch 0 only. ``rotate_to_next_epoch()`` is a stub that
|
|
69
|
+
raises :class:`NotImplementedError`; the actual rotation protocol
|
|
70
|
+
(epoch_rotation_proof, transparency-log carry-over) is reserved for
|
|
71
|
+
v1.0 per spec §24.9.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
epoch_id: int
|
|
75
|
+
dkg_transcript_hash: bytes
|
|
76
|
+
group_pubkey: bytes
|
|
77
|
+
created_at_hlc: HLC
|
|
78
|
+
|
|
79
|
+
def rotate_to_next_epoch(self) -> NamespaceEpoch:
|
|
80
|
+
raise NotImplementedError("v1.0 — see spec §24 reserved fields")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class NamespaceRoot:
|
|
85
|
+
"""Aggregate FROST k-of-n key for a namespace.
|
|
86
|
+
|
|
87
|
+
Attributes
|
|
88
|
+
----------
|
|
89
|
+
group_pubkey:
|
|
90
|
+
32-byte Ed25519 group public key (FROST aggregate).
|
|
91
|
+
k, n:
|
|
92
|
+
Threshold parameters (default 5-of-7, spec §24.2).
|
|
93
|
+
member_kids:
|
|
94
|
+
Founding-cohort kids in declaration order. ``share_indices[i]``
|
|
95
|
+
is the FROST share index for ``member_kids[i]``.
|
|
96
|
+
dkg_transcript_hash:
|
|
97
|
+
BLAKE3-256 of the canonical DKG transcript, bound into
|
|
98
|
+
``NAMESPACE_FOUNDING_ATTEST.dkg_transcript_hash``.
|
|
99
|
+
epoch:
|
|
100
|
+
Current :class:`NamespaceEpoch`. v0.9.x only ever sees epoch 0.
|
|
101
|
+
|
|
102
|
+
Secret shares are kept in ``_shares`` (private) — never exposed
|
|
103
|
+
on the wire and refused by ``__reduce__``.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
group_pubkey: bytes
|
|
107
|
+
k: int
|
|
108
|
+
n: int
|
|
109
|
+
member_kids: list[bytes]
|
|
110
|
+
dkg_transcript_hash: bytes
|
|
111
|
+
epoch: NamespaceEpoch
|
|
112
|
+
_shares: list[frost.FrostShare] = field(default_factory=list, repr=False)
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_dkg(
|
|
116
|
+
cls,
|
|
117
|
+
members: list[Keypair],
|
|
118
|
+
k: int = DEFAULT_K,
|
|
119
|
+
n: int | None = None,
|
|
120
|
+
) -> NamespaceRoot:
|
|
121
|
+
"""Run the trusted-dealer FROST DKG over ``members``.
|
|
122
|
+
|
|
123
|
+
Spec §24.2 `NS-LIFECYCLE-FROST`: ``k >= ceil(2n/3)`` and ``n >= 3``.
|
|
124
|
+
Default parameters are k=5, n=7. ``len(members)`` MUST equal n.
|
|
125
|
+
"""
|
|
126
|
+
if n is None:
|
|
127
|
+
n = len(members)
|
|
128
|
+
if n < 3:
|
|
129
|
+
raise ValueError("namespace founding cohort requires n >= 3 (spec §24.2)")
|
|
130
|
+
if len(members) != n:
|
|
131
|
+
raise ValueError(f"members count {len(members)} != n={n}")
|
|
132
|
+
# ceil(2n/3) — integer arithmetic
|
|
133
|
+
min_k = (2 * n + 2) // 3
|
|
134
|
+
if k < min_k:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
f"k={k} below ceil(2n/3)={min_k} for n={n} (spec §24.2 NS-LIFECYCLE-FROST)"
|
|
137
|
+
)
|
|
138
|
+
if k > n:
|
|
139
|
+
raise ValueError(f"k={k} cannot exceed n={n}")
|
|
140
|
+
|
|
141
|
+
shares = frost.generate_shares(t=k, n=n)
|
|
142
|
+
group_public = shares[0].group_public
|
|
143
|
+
member_kids = [m.kid for m in members]
|
|
144
|
+
share_indices = [s.index for s in shares]
|
|
145
|
+
transcript = _canonical_dkg_transcript(
|
|
146
|
+
member_kids=member_kids,
|
|
147
|
+
k=k,
|
|
148
|
+
n=n,
|
|
149
|
+
group_public=group_public,
|
|
150
|
+
share_indices=share_indices,
|
|
151
|
+
)
|
|
152
|
+
transcript_hash = blake3_32(transcript)
|
|
153
|
+
epoch = NamespaceEpoch(
|
|
154
|
+
epoch_id=0,
|
|
155
|
+
dkg_transcript_hash=transcript_hash,
|
|
156
|
+
group_pubkey=group_public,
|
|
157
|
+
created_at_hlc=HLC.now(node_id=member_kids[0][:8]),
|
|
158
|
+
)
|
|
159
|
+
return cls(
|
|
160
|
+
group_pubkey=group_public,
|
|
161
|
+
k=k,
|
|
162
|
+
n=n,
|
|
163
|
+
member_kids=member_kids,
|
|
164
|
+
dkg_transcript_hash=transcript_hash,
|
|
165
|
+
epoch=epoch,
|
|
166
|
+
_shares=list(shares),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def sign(self, message: bytes, signer_subset: list[int]) -> bytes:
|
|
170
|
+
"""Produce an aggregated FROST signature over ``message``.
|
|
171
|
+
|
|
172
|
+
``signer_subset`` is a list of 1-indexed share indices (matching
|
|
173
|
+
``share_indices`` from the DKG). MUST contain at least ``k``
|
|
174
|
+
distinct indices. Returns the wire signature ``R || z`` (64
|
|
175
|
+
bytes — both 32-byte scalars/points).
|
|
176
|
+
"""
|
|
177
|
+
if len(set(signer_subset)) != len(signer_subset):
|
|
178
|
+
raise ValueError("signer_subset must contain unique indices")
|
|
179
|
+
if len(signer_subset) < self.k:
|
|
180
|
+
raise ValueError(
|
|
181
|
+
f"FROST quorum unmet: need at least k={self.k} signers, got {len(signer_subset)}"
|
|
182
|
+
)
|
|
183
|
+
share_by_index = {s.index: s for s in self._shares}
|
|
184
|
+
chosen: list[frost.FrostShare] = []
|
|
185
|
+
for idx in signer_subset:
|
|
186
|
+
if idx not in share_by_index:
|
|
187
|
+
raise ValueError(f"signer index {idx} not in DKG share set")
|
|
188
|
+
chosen.append(share_by_index[idx])
|
|
189
|
+
R, z = frost.threshold_sign(chosen, message)
|
|
190
|
+
return R + z
|
|
191
|
+
|
|
192
|
+
def verify(self, message: bytes, signature: bytes) -> bool:
|
|
193
|
+
"""Verify a FROST signature produced by :meth:`sign`."""
|
|
194
|
+
if len(signature) != 64:
|
|
195
|
+
return False
|
|
196
|
+
R = signature[:32]
|
|
197
|
+
z = signature[32:]
|
|
198
|
+
return frost.verify(self.group_pubkey, message, R, z, commitments=None)
|
|
199
|
+
|
|
200
|
+
# ---- safety -------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def __reduce__(self) -> Any:
|
|
203
|
+
# Refuse pickle; secret shares would leak.
|
|
204
|
+
raise TypeError("refusing to pickle NamespaceRoot; contains secret FROST shares")
|
hypermind/crypto/pq.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Dual-vendor ML-DSA-65 backend adapter.
|
|
2
|
+
|
|
3
|
+
WS-A supply-chain hardening (v0.6): the production path in
|
|
4
|
+
`hypermind.crypto.signing` uses ``quantcrypt`` (FIPS 204 reference
|
|
5
|
+
implementation) directly. To defend against a single-vendor compromise
|
|
6
|
+
of the post-quantum signature primitive, this module defines a backend
|
|
7
|
+
Protocol with two interchangeable implementations:
|
|
8
|
+
|
|
9
|
+
* :class:`QuantcryptBackend` — wraps ``quantcrypt.dss.MLDSA_65`` (the
|
|
10
|
+
current production dependency).
|
|
11
|
+
* :class:`OqsBackend` — wraps ``oqs.Signature("ML-DSA-65")`` from the
|
|
12
|
+
Open Quantum Safe ``liboqs`` Python bindings.
|
|
13
|
+
|
|
14
|
+
Both vendors implement the same NIST FIPS 204 ML-DSA-65 algorithm, so a
|
|
15
|
+
keypair generated by one MUST verify under the other and vice versa.
|
|
16
|
+
This dual-vendor cross-check is asserted in
|
|
17
|
+
``tests/test_pq_dual_vendor.py``.
|
|
18
|
+
|
|
19
|
+
This module deliberately does **not** alter any production code path;
|
|
20
|
+
it only adds the Protocol + two adapters. The cutover is gated to a
|
|
21
|
+
later milestone once the cross-vendor cross-check has been running in
|
|
22
|
+
CI for at least one release cycle.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Protocol, runtime_checkable
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@runtime_checkable
|
|
31
|
+
class MlDsaBackend(Protocol):
|
|
32
|
+
"""ML-DSA-65 signing backend.
|
|
33
|
+
|
|
34
|
+
All implementations MUST be byte-for-byte interoperable on
|
|
35
|
+
``verify`` — a signature emitted by one backend must verify under
|
|
36
|
+
any other backend with the matching public key.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
|
|
41
|
+
def keygen(self, seed: bytes | None = None) -> tuple[bytes, bytes]:
|
|
42
|
+
"""Generate a keypair. Returns ``(public_key, secret_key)``.
|
|
43
|
+
|
|
44
|
+
``seed`` is accepted for API symmetry; backends MAY ignore it
|
|
45
|
+
if they do not expose deterministic key generation. Callers
|
|
46
|
+
SHOULD NOT rely on ``seed`` for determinism across backends.
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
def sign(self, sk: bytes, msg: bytes) -> bytes:
|
|
51
|
+
"""Sign ``msg`` under secret key ``sk``. Returns the raw signature."""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
def verify(self, pk: bytes, msg: bytes, sig: bytes) -> bool:
|
|
55
|
+
"""Verify ``sig`` over ``msg`` under public key ``pk``.
|
|
56
|
+
|
|
57
|
+
Returns ``True`` on success, ``False`` on any verification
|
|
58
|
+
failure (bad signature, malformed input, length mismatch).
|
|
59
|
+
Implementations MUST NOT raise on cryptographic failure.
|
|
60
|
+
"""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class QuantcryptBackend:
|
|
65
|
+
"""ML-DSA-65 backend backed by ``quantcrypt`` (FIPS 204 reference).
|
|
66
|
+
|
|
67
|
+
This is the production backend used by
|
|
68
|
+
:mod:`hypermind.crypto.signing` today.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
name = "quantcrypt"
|
|
72
|
+
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
# Lazy import — quantcrypt is a runtime dep but importing it
|
|
75
|
+
# lazily keeps this module cheap to import for type-only users.
|
|
76
|
+
from quantcrypt.dss import MLDSA_65
|
|
77
|
+
|
|
78
|
+
self._impl = MLDSA_65()
|
|
79
|
+
|
|
80
|
+
def keygen(self, seed: bytes | None = None) -> tuple[bytes, bytes]:
|
|
81
|
+
# quantcrypt's MLDSA_65.keygen() does not expose seed input.
|
|
82
|
+
# The `seed` parameter is accepted for Protocol conformance.
|
|
83
|
+
del seed
|
|
84
|
+
pk, sk = self._impl.keygen()
|
|
85
|
+
return bytes(pk), bytes(sk)
|
|
86
|
+
|
|
87
|
+
def sign(self, sk: bytes, msg: bytes) -> bytes:
|
|
88
|
+
return bytes(self._impl.sign(sk, msg))
|
|
89
|
+
|
|
90
|
+
def verify(self, pk: bytes, msg: bytes, sig: bytes) -> bool:
|
|
91
|
+
from quantcrypt.errors import DSSVerifyFailedError
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
return bool(self._impl.verify(pk, msg, sig))
|
|
95
|
+
except DSSVerifyFailedError:
|
|
96
|
+
return False
|
|
97
|
+
except Exception:
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class OqsBackend:
|
|
102
|
+
"""ML-DSA-65 backend backed by ``liboqs`` (Open Quantum Safe).
|
|
103
|
+
|
|
104
|
+
Lazy-loaded: importing this class does not require ``oqs`` to be
|
|
105
|
+
installed, but instantiating it does. Install via the
|
|
106
|
+
``crypto-pq-test`` optional extra:
|
|
107
|
+
|
|
108
|
+
.. code-block:: bash
|
|
109
|
+
|
|
110
|
+
pip install -e ".[crypto-pq-test]"
|
|
111
|
+
|
|
112
|
+
Note: ``liboqs`` is intentionally test-only — it is not on the
|
|
113
|
+
runtime install path. The dual-vendor invariant is asserted in
|
|
114
|
+
CI; production deployments only require ``quantcrypt``.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
name = "liboqs"
|
|
118
|
+
|
|
119
|
+
def __init__(self) -> None:
|
|
120
|
+
try:
|
|
121
|
+
import oqs # type: ignore[import-not-found]
|
|
122
|
+
except ImportError as exc: # pragma: no cover — exercised in skip path
|
|
123
|
+
raise ImportError(
|
|
124
|
+
"OqsBackend requires the 'oqs' Python package (liboqs bindings). "
|
|
125
|
+
"Install it via the crypto-pq-test extra: "
|
|
126
|
+
'pip install -e ".[crypto-pq-test]"'
|
|
127
|
+
) from exc
|
|
128
|
+
# We hold the module reference, not a long-lived Signature
|
|
129
|
+
# object: oqs.Signature is stateful (holds a secret key after
|
|
130
|
+
# generate_keypair), so we instantiate fresh per call.
|
|
131
|
+
self._oqs = oqs
|
|
132
|
+
|
|
133
|
+
def keygen(self, seed: bytes | None = None) -> tuple[bytes, bytes]:
|
|
134
|
+
del seed # liboqs does not expose seeded keygen
|
|
135
|
+
with self._oqs.Signature("ML-DSA-65") as signer:
|
|
136
|
+
pk = bytes(signer.generate_keypair())
|
|
137
|
+
sk = bytes(signer.export_secret_key())
|
|
138
|
+
return pk, sk
|
|
139
|
+
|
|
140
|
+
def sign(self, sk: bytes, msg: bytes) -> bytes:
|
|
141
|
+
with self._oqs.Signature("ML-DSA-65", sk) as signer:
|
|
142
|
+
return bytes(signer.sign(msg))
|
|
143
|
+
|
|
144
|
+
def verify(self, pk: bytes, msg: bytes, sig: bytes) -> bool:
|
|
145
|
+
try:
|
|
146
|
+
with self._oqs.Signature("ML-DSA-65") as verifier:
|
|
147
|
+
return bool(verifier.verify(msg, sig, pk))
|
|
148
|
+
except Exception:
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
__all__ = ["MlDsaBackend", "OqsBackend", "QuantcryptBackend"]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Sender-derived Room epoch key chain.
|
|
2
|
+
|
|
3
|
+
Lightweight one-way ratchet used by Rooms in v0.9.x to bind ciphertexts to a
|
|
4
|
+
specific epoch boundary. Each epoch's key is derived from the previous via
|
|
5
|
+
HKDF-Expand, so a key from epoch N cannot decrypt traffic encrypted under the
|
|
6
|
+
AEAD key derived from epoch N+1's chain key (forward secrecy at the boundary).
|
|
7
|
+
|
|
8
|
+
# MLS-SWAP-POINT: in v1.0 this module is replaced by an MLS TreeKEM group state.
|
|
9
|
+
# Callers depend only on `RoomEpochKey.derive_aead_key()` returning a 32-byte
|
|
10
|
+
# AEAD key bound to the current epoch — keep that surface stable across the swap.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import struct
|
|
16
|
+
from dataclasses import dataclass, replace
|
|
17
|
+
|
|
18
|
+
from cryptography.hazmat.primitives import hashes, hmac
|
|
19
|
+
|
|
20
|
+
_HASH_LEN = 32 # SHA-256
|
|
21
|
+
_KEY_LEN = 32 # AEAD key length (AES-256 / ChaCha20)
|
|
22
|
+
|
|
23
|
+
_LABEL_ADVANCE = b"hm/room-epoch/advance"
|
|
24
|
+
_LABEL_AEAD = b"hm/room-epoch/aead-key"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _hmac_sha256(key: bytes, data: bytes) -> bytes:
|
|
28
|
+
h = hmac.HMAC(key, hashes.SHA256())
|
|
29
|
+
h.update(data)
|
|
30
|
+
return h.finalize()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _hkdf_extract(salt: bytes, ikm: bytes) -> bytes:
|
|
34
|
+
if not salt:
|
|
35
|
+
salt = b"\x00" * _HASH_LEN
|
|
36
|
+
return _hmac_sha256(salt, ikm)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes:
|
|
40
|
+
n = (length + _HASH_LEN - 1) // _HASH_LEN
|
|
41
|
+
if n > 255:
|
|
42
|
+
raise ValueError("HKDF-Expand: requested length too large")
|
|
43
|
+
out = b""
|
|
44
|
+
t = b""
|
|
45
|
+
for i in range(1, n + 1):
|
|
46
|
+
t = _hmac_sha256(prk, t + info + bytes([i]))
|
|
47
|
+
out += t
|
|
48
|
+
return out[:length]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class RoomEpochKey:
|
|
53
|
+
"""A Room's chain key at a specific epoch.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
room_id: Stable identifier of the Room (any byte string).
|
|
57
|
+
epoch: Monotonically increasing counter, starts at 0.
|
|
58
|
+
key: 32-byte chain key for this epoch. Use `derive_aead_key()` for
|
|
59
|
+
encryption — never use this field directly as an AEAD key.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
room_id: bytes
|
|
63
|
+
epoch: int
|
|
64
|
+
key: bytes
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_root(cls, room_root_secret: bytes, room_id: bytes) -> RoomEpochKey:
|
|
68
|
+
"""Initialise epoch 0 from a Room root secret."""
|
|
69
|
+
if not isinstance(room_root_secret, bytes) or len(room_root_secret) == 0:
|
|
70
|
+
raise ValueError("room_root_secret must be non-empty bytes")
|
|
71
|
+
if not isinstance(room_id, bytes) or len(room_id) == 0:
|
|
72
|
+
raise ValueError("room_id must be non-empty bytes")
|
|
73
|
+
prk = _hkdf_extract(room_id, room_root_secret)
|
|
74
|
+
info = _LABEL_ADVANCE + room_id + struct.pack(">Q", 0)
|
|
75
|
+
key0 = _hkdf_expand(prk, info, _HASH_LEN)
|
|
76
|
+
return cls(room_id=room_id, epoch=0, key=key0)
|
|
77
|
+
|
|
78
|
+
def advance(self) -> RoomEpochKey:
|
|
79
|
+
"""Return the next epoch's chain key.
|
|
80
|
+
|
|
81
|
+
The previous chain key is the HKDF PRK; the next-epoch info string binds
|
|
82
|
+
`(room_id, next_epoch)` so distinct rooms never collide and the chain is
|
|
83
|
+
deterministic given a fixed root.
|
|
84
|
+
"""
|
|
85
|
+
next_epoch = self.epoch + 1
|
|
86
|
+
info = _LABEL_ADVANCE + self.room_id + struct.pack(">Q", next_epoch)
|
|
87
|
+
next_key = _hkdf_expand(self.key, info, _HASH_LEN)
|
|
88
|
+
return replace(self, epoch=next_epoch, key=next_key)
|
|
89
|
+
|
|
90
|
+
def derive_aead_key(self) -> bytes:
|
|
91
|
+
"""Return a 32-byte AEAD key bound to the current epoch.
|
|
92
|
+
|
|
93
|
+
Distinct from the chain key so a leaked AEAD key does not let an attacker
|
|
94
|
+
ratchet forward.
|
|
95
|
+
"""
|
|
96
|
+
info = _LABEL_AEAD + self.room_id + struct.pack(">Q", self.epoch)
|
|
97
|
+
return _hkdf_expand(self.key, info, _KEY_LEN)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Signing primitives — Ed25519 (v0.1) + ML-DSA-65 hybrid (v0.2).
|
|
2
|
+
|
|
3
|
+
Composite signing follows draft-ietf-lamps-pq-composite-sigs-16
|
|
4
|
+
(`id-MLDSA65-Ed25519-SHA512`): the composite signature is the
|
|
5
|
+
concatenation `Ed25519_sig || ML-DSA-65_sig`, both signed over the
|
|
6
|
+
same canonical preimage. Verification requires BOTH signatures to
|
|
7
|
+
verify — defeats single-key compromise.
|
|
8
|
+
|
|
9
|
+
A-ALG-BOUND invariant: every envelope discriminator is in the signed
|
|
10
|
+
preimage. See `wire.canonical_sig_input`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import secrets
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from nacl.exceptions import BadSignatureError
|
|
19
|
+
from nacl.signing import SigningKey, VerifyKey
|
|
20
|
+
|
|
21
|
+
# Real ML-DSA-65 from quantcrypt (NIST FIPS 204 reference impl).
|
|
22
|
+
from quantcrypt.dss import MLDSA_65
|
|
23
|
+
from quantcrypt.errors import DSSVerifyFailedError
|
|
24
|
+
|
|
25
|
+
# COSE algorithm identifiers (RFC 9053; LAMPS draft for composite)
|
|
26
|
+
ALG_ED25519 = -8
|
|
27
|
+
ALG_MLDSA65_ED25519 = -49 # composite — id-MLDSA65-Ed25519-SHA512
|
|
28
|
+
|
|
29
|
+
ED25519_SIG_LEN = 64
|
|
30
|
+
ED25519_PK_LEN = 32
|
|
31
|
+
|
|
32
|
+
# ML-DSA-65 lengths from FIPS 204
|
|
33
|
+
MLDSA65_SIG_LEN = 3309
|
|
34
|
+
MLDSA65_PK_LEN = 1952
|
|
35
|
+
|
|
36
|
+
_MLDSA = MLDSA_65()
|
|
37
|
+
|
|
38
|
+
# --------------------------------------------------------------------------- #
|
|
39
|
+
# LAMPS draft-ietf-lamps-pq-composite-sigs-16 §6 domain-separated preimage.
|
|
40
|
+
#
|
|
41
|
+
# Per draft §6, the message signed by each component algorithm is:
|
|
42
|
+
# M' = Domain || OID || len(ctx) || ctx || M
|
|
43
|
+
#
|
|
44
|
+
# where:
|
|
45
|
+
# * Domain = the ASCII string "CompositeAlgorithmSignatures2025"
|
|
46
|
+
# * OID = DER encoding of the composite algorithm OID
|
|
47
|
+
# (id-MLDSA65-Ed25519-SHA512 = 2.16.840.1.101.3.4.3.21)
|
|
48
|
+
# * ctx = caller-supplied context bytes (default empty)
|
|
49
|
+
# * M = the original application message
|
|
50
|
+
#
|
|
51
|
+
# This prefix prevents (a) signature stripping — taking only the Ed25519
|
|
52
|
+
# half and re-using it as a non-composite Ed25519 signature over M, and
|
|
53
|
+
# (b) cross-protocol substitution against any signer that signs the raw
|
|
54
|
+
# message with no domain separation.
|
|
55
|
+
#
|
|
56
|
+
# OID encoding: this is the canonical DER encoding of
|
|
57
|
+
# 2.16.840.1.101.3.4.3.21 (id-MLDSA65-Ed25519-SHA512), the assignment
|
|
58
|
+
# tracked by the LAMPS Composite ML-DSA draft. The previous constant
|
|
59
|
+
# (06092a864886f70d010721) was a documented placeholder pointing at an
|
|
60
|
+
# unrelated RSA arc; while internal verify worked (signer and verifier
|
|
61
|
+
# shared the wrong bytes), any LAMPS-conformant external verifier would
|
|
62
|
+
# have rejected our signatures and we would have rejected theirs.
|
|
63
|
+
# Closes self-heal Cycle-3 Finding #21.
|
|
64
|
+
#
|
|
65
|
+
# Wire compatibility: composite signatures created under the placeholder
|
|
66
|
+
# OID will NOT verify under the canonical OID. Operators rotating across
|
|
67
|
+
# this upgrade must accept both during cutover; see
|
|
68
|
+
# docs/migrate/v0.x-to-v1.0.md for the deprecation schedule.
|
|
69
|
+
# --------------------------------------------------------------------------- #
|
|
70
|
+
_LAMPS_DOMAIN: bytes = b"CompositeAlgorithmSignatures2025"
|
|
71
|
+
# Canonical DER for OID 2.16.840.1.101.3.4.3.21:
|
|
72
|
+
# 06 09 60 86 48 01 65 03 04 03 15
|
|
73
|
+
# tag 0x06 (OBJECT IDENTIFIER), length 0x09 (9 content bytes), then OID body.
|
|
74
|
+
_OID_MLDSA65_ED25519: bytes = bytes.fromhex("0609608648016503040315")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _lamps_preimage(message: bytes, ctx: bytes = b"") -> bytes:
|
|
78
|
+
"""Build the LAMPS §6 domain-separated preimage.
|
|
79
|
+
|
|
80
|
+
`ctx` is the optional context string (LAMPS §6); we cap it at 255
|
|
81
|
+
bytes per the draft's single-byte length field.
|
|
82
|
+
"""
|
|
83
|
+
if len(ctx) > 255:
|
|
84
|
+
raise ValueError("LAMPS context must be ≤ 255 bytes")
|
|
85
|
+
return _LAMPS_DOMAIN + _OID_MLDSA65_ED25519 + len(ctx).to_bytes(1, "big") + ctx + message
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Keypair:
|
|
90
|
+
"""Signing keypair.
|
|
91
|
+
|
|
92
|
+
For Ed25519 (alg=-8): `secret_bytes` and `public_bytes` are the
|
|
93
|
+
raw 32-byte / 32-byte Ed25519 material. `kid` = public_bytes.
|
|
94
|
+
|
|
95
|
+
For composite (alg=-49): `secret_bytes` is `ed_sk(32) || mldsa_sk`,
|
|
96
|
+
`public_bytes` is `ed_pk(32) || mldsa_pk`. `kid` is the Ed25519
|
|
97
|
+
public-key half (first 32 bytes of `public_bytes`) — this preserves
|
|
98
|
+
the v0.1 → v0.2 address-stability contract so an agent's identity
|
|
99
|
+
survives algorithm rotation from Ed25519 to composite.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
secret_bytes: bytes
|
|
103
|
+
public_bytes: bytes
|
|
104
|
+
alg: int = ALG_ED25519
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def generate(cls, *, alg: int = ALG_ED25519) -> Keypair:
|
|
108
|
+
if alg == ALG_ED25519:
|
|
109
|
+
sk = SigningKey.generate()
|
|
110
|
+
return cls(
|
|
111
|
+
secret_bytes=bytes(sk),
|
|
112
|
+
public_bytes=bytes(sk.verify_key),
|
|
113
|
+
alg=alg,
|
|
114
|
+
)
|
|
115
|
+
if alg == ALG_MLDSA65_ED25519:
|
|
116
|
+
ed_sk = SigningKey.generate()
|
|
117
|
+
mldsa_pk, mldsa_sk = _MLDSA.keygen()
|
|
118
|
+
return cls(
|
|
119
|
+
secret_bytes=bytes(ed_sk) + mldsa_sk,
|
|
120
|
+
public_bytes=bytes(ed_sk.verify_key) + mldsa_pk,
|
|
121
|
+
alg=alg,
|
|
122
|
+
)
|
|
123
|
+
raise NotImplementedError(f"unsupported alg {alg}")
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def from_seed(cls, seed: bytes) -> Keypair:
|
|
127
|
+
"""Deterministic Ed25519 keypair from a 32-byte seed."""
|
|
128
|
+
if len(seed) != 32:
|
|
129
|
+
raise ValueError("seed must be 32 bytes")
|
|
130
|
+
sk = SigningKey(seed)
|
|
131
|
+
return cls(
|
|
132
|
+
secret_bytes=bytes(sk),
|
|
133
|
+
public_bytes=bytes(sk.verify_key),
|
|
134
|
+
alg=ALG_ED25519,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def sign(self, message: bytes) -> bytes:
|
|
138
|
+
"""Sign — returns raw signature (no envelope).
|
|
139
|
+
|
|
140
|
+
Ed25519: 64-byte raw signature.
|
|
141
|
+
Composite: 64-byte Ed25519 sig || 3309-byte ML-DSA-65 sig.
|
|
142
|
+
"""
|
|
143
|
+
if self.alg == ALG_ED25519:
|
|
144
|
+
sk = SigningKey(self.secret_bytes)
|
|
145
|
+
return sk.sign(message).signature
|
|
146
|
+
if self.alg == ALG_MLDSA65_ED25519:
|
|
147
|
+
ed_sk = SigningKey(self.secret_bytes[:32])
|
|
148
|
+
mldsa_sk = self.secret_bytes[32:]
|
|
149
|
+
# LAMPS draft-16 §6: BOTH component signatures are computed
|
|
150
|
+
# over the same domain-separated preimage. This blocks the
|
|
151
|
+
# "strip-and-reuse the Ed25519 half" downgrade attack.
|
|
152
|
+
preimage = _lamps_preimage(message)
|
|
153
|
+
ed_sig = ed_sk.sign(preimage).signature
|
|
154
|
+
mldsa_sig = _MLDSA.sign(mldsa_sk, preimage)
|
|
155
|
+
return ed_sig + mldsa_sig
|
|
156
|
+
raise NotImplementedError(f"sign: unsupported alg {self.alg}")
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def ed_public_bytes(self) -> bytes:
|
|
160
|
+
"""Ed25519 public-key half (always 32 bytes)."""
|
|
161
|
+
return self.public_bytes[:ED25519_PK_LEN]
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def mldsa_public_bytes(self) -> bytes:
|
|
165
|
+
"""ML-DSA-65 public-key half. Empty for Ed25519-only keys."""
|
|
166
|
+
if self.alg == ALG_MLDSA65_ED25519:
|
|
167
|
+
return self.public_bytes[ED25519_PK_LEN:]
|
|
168
|
+
return b""
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def kid(self) -> bytes:
|
|
172
|
+
"""Stable agent identity = first 32 bytes of public-key bytes.
|
|
173
|
+
|
|
174
|
+
For Ed25519, this is the public key itself. For composite, this
|
|
175
|
+
is the Ed25519 half — preserves the v0.1 → v0.2 key-identity
|
|
176
|
+
contract so an agent's address survives algorithm rotation.
|
|
177
|
+
"""
|
|
178
|
+
return self.public_bytes[:ED25519_PK_LEN]
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def did_key(self) -> str:
|
|
182
|
+
return f"did:key:z{self.kid.hex()[:24]}"
|
|
183
|
+
|
|
184
|
+
def __repr__(self) -> str:
|
|
185
|
+
# Never render `secret_bytes` — it would land in logs / tracebacks /
|
|
186
|
+
# `pytest -v` output. Show only the public kid and algorithm.
|
|
187
|
+
return f"Keypair(kid={self.kid.hex()[:16]}..., alg={self.alg}, secret=<redacted>)"
|
|
188
|
+
|
|
189
|
+
def __str__(self) -> str:
|
|
190
|
+
return self.__repr__()
|
|
191
|
+
|
|
192
|
+
def __reduce__(self):
|
|
193
|
+
# Pickle is a known leak vector — a serialized Keypair would carry
|
|
194
|
+
# `secret_bytes` to disk / over the wire. Refuse outright; callers
|
|
195
|
+
# who need cross-process sharing must use the in-memory key store.
|
|
196
|
+
raise TypeError("refusing to pickle secret material; use the in-memory store")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def verify_ed25519(public_key: bytes, signature: bytes, message: bytes) -> bool:
|
|
200
|
+
try:
|
|
201
|
+
VerifyKey(public_key).verify(message, signature)
|
|
202
|
+
return True
|
|
203
|
+
except BadSignatureError:
|
|
204
|
+
return False
|
|
205
|
+
except Exception:
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def verify_composite(composite_pk: bytes, composite_sig: bytes, message: bytes) -> bool:
|
|
210
|
+
"""Hybrid verify: BOTH Ed25519 and ML-DSA-65 must validate.
|
|
211
|
+
|
|
212
|
+
composite_pk = Ed25519_pk(32) || ML-DSA-65_pk(1952)
|
|
213
|
+
composite_sig = Ed25519_sig(64) || ML-DSA-65_sig(3309)
|
|
214
|
+
"""
|
|
215
|
+
if len(composite_pk) != ED25519_PK_LEN + MLDSA65_PK_LEN:
|
|
216
|
+
return False
|
|
217
|
+
if len(composite_sig) != ED25519_SIG_LEN + MLDSA65_SIG_LEN:
|
|
218
|
+
return False
|
|
219
|
+
ed_pk = composite_pk[:ED25519_PK_LEN]
|
|
220
|
+
mldsa_pk = composite_pk[ED25519_PK_LEN:]
|
|
221
|
+
ed_sig = composite_sig[:ED25519_SIG_LEN]
|
|
222
|
+
mldsa_sig = composite_sig[ED25519_SIG_LEN:]
|
|
223
|
+
# LAMPS draft-16 §6: verify both halves over the prefixed preimage.
|
|
224
|
+
preimage = _lamps_preimage(message)
|
|
225
|
+
if not verify_ed25519(ed_pk, ed_sig, preimage):
|
|
226
|
+
return False
|
|
227
|
+
try:
|
|
228
|
+
return _MLDSA.verify(mldsa_pk, preimage, mldsa_sig)
|
|
229
|
+
except DSSVerifyFailedError:
|
|
230
|
+
return False
|
|
231
|
+
except Exception:
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def verify_signature(alg: int, public_key: bytes, signature: bytes, message: bytes) -> bool:
|
|
236
|
+
"""Algorithm-dispatched verify."""
|
|
237
|
+
if alg == ALG_ED25519:
|
|
238
|
+
return verify_ed25519(public_key, signature, message)
|
|
239
|
+
if alg == ALG_MLDSA65_ED25519:
|
|
240
|
+
return verify_composite(public_key, signature, message)
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def random_seed() -> bytes:
|
|
245
|
+
return secrets.token_bytes(32)
|