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,219 @@
|
|
|
1
|
+
"""Contract-Net Protocol — RFP / Bid / Award / Dispute (spec §27, T3).
|
|
2
|
+
|
|
3
|
+
Lineage: FIPA Contract Net Protocol (FIPA00029). HyperMind's profile
|
|
4
|
+
adds three things FIPA does not specify:
|
|
5
|
+
|
|
6
|
+
1. **Cryptographic settlement.** Every RFP, BID, and AWARD is a signed
|
|
7
|
+
:class:`hypermind.wire.SignedStatement` — no record of the auction
|
|
8
|
+
exists outside the SCITT log.
|
|
9
|
+
2. **FROST quorum awards.** When a consortium of k-of-n principals must
|
|
10
|
+
co-sign an AWARD, the AWARD record is signed by a FROST aggregate
|
|
11
|
+
over the canonical preimage `H("hm:cnp-award:" || rfp_id || winning_bid_kid)`.
|
|
12
|
+
3. **Disputable awards.** A losing bidder can dispute the AWARD via the
|
|
13
|
+
regular Dispute FSM; the bond's `topic_scope_hash` binds the dispute
|
|
14
|
+
to ``"rfp:" + rfp_id`` so a wash-trade across topics cannot slash
|
|
15
|
+
the bond.
|
|
16
|
+
|
|
17
|
+
This module owns the **scoring** layer. The agent verbs that publish the
|
|
18
|
+
signed records live on :class:`hypermind.agent.HyperMindAgent`.
|
|
19
|
+
|
|
20
|
+
Scoring is pluggable via the :class:`BidScorer` Protocol so federations
|
|
21
|
+
can swap in domain-specific scoring policies (e.g. resource-cost
|
|
22
|
+
weighted, latency-weighted) without touching the protocol itself. The
|
|
23
|
+
default :class:`ReputationWeightedScorer` combines the bidder's Beta
|
|
24
|
+
posterior on the topic with the bidder's claimed self-score and a
|
|
25
|
+
Brier-derived calibration term — so a high-reputation bidder beats a
|
|
26
|
+
low-reputation bidder at equal self-score.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import hashlib
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import Any, Protocol
|
|
34
|
+
|
|
35
|
+
from hypermind.knowledge.reputation import ReputationKernel
|
|
36
|
+
|
|
37
|
+
# Domain tag for the FROST award transcript context. v1: length-prefixed
|
|
38
|
+
# encoding (mirrors the witness-quorum tag `:v2` upgrade in dispute.py).
|
|
39
|
+
AWARD_DOMAIN_TAG = b"hypermind:cnp-award:v1"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def award_preimage(rfp_id: bytes, winning_bid_kid: bytes) -> bytes:
|
|
43
|
+
"""Canonical preimage every AWARD signer (FROST or single) signs.
|
|
44
|
+
|
|
45
|
+
Domain-separated under :data:`AWARD_DOMAIN_TAG` so an AWARD signature
|
|
46
|
+
cannot be replayed as any other HyperMind signed payload. Both inputs
|
|
47
|
+
are length-prefixed with a 4-byte big-endian length to defeat boundary
|
|
48
|
+
ambiguity (mirrors the v2 witness-cosign encoding).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def _f(b: bytes) -> bytes:
|
|
52
|
+
return len(b).to_bytes(4, "big") + b
|
|
53
|
+
|
|
54
|
+
h = hashlib.sha256()
|
|
55
|
+
h.update(_f(AWARD_DOMAIN_TAG))
|
|
56
|
+
h.update(_f(bytes(rfp_id)))
|
|
57
|
+
h.update(_f(bytes(winning_bid_kid)))
|
|
58
|
+
return h.digest()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def topic_scope_for_rfp(rfp_id: bytes) -> str:
|
|
62
|
+
"""Bond topic-scope string for disputes filed against an AWARD.
|
|
63
|
+
|
|
64
|
+
The dispute FSM hashes this string with `_hash_topic_scope` (BLAKE3)
|
|
65
|
+
when sealing the bond. The string itself is the stable identifier we
|
|
66
|
+
use everywhere AWARD disputes are negotiated — so the topic-scope
|
|
67
|
+
binding is `"rfp:" + hex(rfp_id)`.
|
|
68
|
+
"""
|
|
69
|
+
return "rfp:" + bytes(rfp_id).hex()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# -----------------------------------------------------------------------
|
|
73
|
+
# Records — in-memory views of the signed RFP/BID/AWARD payloads
|
|
74
|
+
# -----------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class RFP:
|
|
79
|
+
"""In-memory view of an RFP_OPEN record's payload + identifiers."""
|
|
80
|
+
|
|
81
|
+
rfp_id: bytes
|
|
82
|
+
issuer_kid: bytes
|
|
83
|
+
topic: str
|
|
84
|
+
requirements: dict[str, Any]
|
|
85
|
+
deadline_hlc_ms: int
|
|
86
|
+
bidder_filter: dict[str, Any] | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Bid:
|
|
91
|
+
"""In-memory view of a BID record's payload + identifiers."""
|
|
92
|
+
|
|
93
|
+
rfp_id: bytes
|
|
94
|
+
bid_kid: bytes
|
|
95
|
+
bidder_kid: bytes
|
|
96
|
+
payload: dict[str, Any]
|
|
97
|
+
bid_score_self: float
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class Award:
|
|
102
|
+
"""In-memory view of an AWARD record's payload + identifiers."""
|
|
103
|
+
|
|
104
|
+
rfp_id: bytes
|
|
105
|
+
award_kid: bytes
|
|
106
|
+
winning_bid_kid: bytes
|
|
107
|
+
winning_bidder_kid: bytes
|
|
108
|
+
award_signer_kids: list[bytes] = field(default_factory=list)
|
|
109
|
+
revoked: bool = False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# -----------------------------------------------------------------------
|
|
113
|
+
# Scoring
|
|
114
|
+
# -----------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class BidScorer(Protocol):
|
|
118
|
+
"""Pluggable bid-scoring policy.
|
|
119
|
+
|
|
120
|
+
Implementations MUST be pure functions of (bid, requirements,
|
|
121
|
+
reputation_kernel). The `award()` agent verb invokes the configured
|
|
122
|
+
scorer for every received bid that passed the bidder_filter, then
|
|
123
|
+
awards the highest-scoring one.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def score(
|
|
127
|
+
self,
|
|
128
|
+
bid: Bid,
|
|
129
|
+
requirements: dict[str, Any],
|
|
130
|
+
reputation_kernel: ReputationKernel,
|
|
131
|
+
topic: str,
|
|
132
|
+
) -> float:
|
|
133
|
+
"""Return a real-valued score; higher wins ties resolved by bid_kid."""
|
|
134
|
+
...
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ReputationWeightedScorer:
|
|
138
|
+
"""Default scorer: rep-weighted self-score with calibration shrinkage.
|
|
139
|
+
|
|
140
|
+
Score formula (∈ [0, 2.5] in practice):
|
|
141
|
+
|
|
142
|
+
score = (rep_score * calibration) * bid_score_self
|
|
143
|
+
+ 0.05 * rep_score # tie-breaker for equal self-score
|
|
144
|
+
|
|
145
|
+
where:
|
|
146
|
+
- ``rep_score`` is the Beta posterior mean for (bidder, topic) in
|
|
147
|
+
the supplied :class:`ReputationKernel`.
|
|
148
|
+
- ``calibration`` is the Brier-derived multiplicative shrinkage
|
|
149
|
+
in [0.5, 2.0] (same `calibration_adjustment` used by `consult()`),
|
|
150
|
+
defaulting to 1.0 when the agent has no oracle resolutions yet.
|
|
151
|
+
- ``bid_score_self`` is the bidder's claimed posterior on
|
|
152
|
+
delivering the requirement, supplied by the caller of `bid()`.
|
|
153
|
+
|
|
154
|
+
The `+ 0.05 * rep_score` term breaks ties when two bidders quote
|
|
155
|
+
identical self-scores — the higher-reputation bidder wins. This is
|
|
156
|
+
the property pinned by `tests/test_contract_net_reputation_scoring.py`.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def score(
|
|
160
|
+
self,
|
|
161
|
+
bid: Bid,
|
|
162
|
+
requirements: dict[str, Any],
|
|
163
|
+
reputation_kernel: ReputationKernel,
|
|
164
|
+
topic: str,
|
|
165
|
+
) -> float:
|
|
166
|
+
rep_score = reputation_kernel.snapshot(bid.bidder_kid, topic).score
|
|
167
|
+
try:
|
|
168
|
+
calibration = reputation_kernel.calibration_adjustment(bid.bidder_kid, topic)
|
|
169
|
+
except Exception:
|
|
170
|
+
calibration = 1.0
|
|
171
|
+
weighted = rep_score * calibration * float(bid.bid_score_self)
|
|
172
|
+
return weighted + 0.05 * rep_score
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# -----------------------------------------------------------------------
|
|
176
|
+
# Bidder-filter helpers
|
|
177
|
+
# -----------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def passes_bidder_filter(
|
|
181
|
+
bid: Bid,
|
|
182
|
+
bidder_filter: dict[str, Any] | None,
|
|
183
|
+
reputation_kernel: ReputationKernel,
|
|
184
|
+
topic: str,
|
|
185
|
+
) -> tuple[bool, str | None]:
|
|
186
|
+
"""Return (allowed, reject_rule_id).
|
|
187
|
+
|
|
188
|
+
The bidder_filter is an optional dict on the RFP_OPEN record. When
|
|
189
|
+
set, it carries:
|
|
190
|
+
|
|
191
|
+
- ``min_reputation`` (float in [0, 1]) — Sybil floor.
|
|
192
|
+
- ``topic`` (str) — must match the BID's RFP topic.
|
|
193
|
+
|
|
194
|
+
A bid that fails any predicate is rejected with rule_id
|
|
195
|
+
``HM-CNP-BIDDER-FILTER`` (Sybil floor) — published as a BID_REJECT
|
|
196
|
+
record by the auctioneer before the AWARD is computed. This is the
|
|
197
|
+
SHOULD in spec §27.
|
|
198
|
+
"""
|
|
199
|
+
if bidder_filter is None:
|
|
200
|
+
return True, None
|
|
201
|
+
min_rep = float(bidder_filter.get("min_reputation", 0.0))
|
|
202
|
+
if min_rep > 0.0:
|
|
203
|
+
score = reputation_kernel.snapshot(bid.bidder_kid, topic).score
|
|
204
|
+
if score < min_rep:
|
|
205
|
+
return False, "HM-CNP-BIDDER-FILTER"
|
|
206
|
+
return True, None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
__all__ = [
|
|
210
|
+
"AWARD_DOMAIN_TAG",
|
|
211
|
+
"RFP",
|
|
212
|
+
"Award",
|
|
213
|
+
"Bid",
|
|
214
|
+
"BidScorer",
|
|
215
|
+
"ReputationWeightedScorer",
|
|
216
|
+
"award_preimage",
|
|
217
|
+
"passes_bidder_filter",
|
|
218
|
+
"topic_scope_for_rfp",
|
|
219
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Cryptographic primitives — signing, hashing, HLC, domain tags.
|
|
2
|
+
|
|
3
|
+
v0.1 emits Ed25519 only. ML-DSA-65 hybrid emission gated to v0.2 but
|
|
4
|
+
`signature_alg` is in the signed COSE preimage from day 1 (A-ALG-BOUND).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from hypermind.crypto.domain import DomainTag, domain_separated_hash
|
|
8
|
+
from hypermind.crypto.hashing import blake3_32, sha256_kid
|
|
9
|
+
from hypermind.crypto.hlc import HLC, HLCSkewError
|
|
10
|
+
from hypermind.crypto.signing import Keypair, verify_ed25519
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"HLC",
|
|
14
|
+
"DomainTag",
|
|
15
|
+
"HLCSkewError",
|
|
16
|
+
"Keypair",
|
|
17
|
+
"blake3_32",
|
|
18
|
+
"domain_separated_hash",
|
|
19
|
+
"sha256_kid",
|
|
20
|
+
"verify_ed25519",
|
|
21
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""COSE_Encrypt0 (RFC 9052 §5.2) using HPKE-derived AEAD keys.
|
|
2
|
+
|
|
3
|
+
Single-recipient L3 envelope: a canonical CBOR `[protected, unprotected, ciphertext]`
|
|
4
|
+
where `protected` carries the HPKE suite id (`alg`) and recipient `kid`, while
|
|
5
|
+
`unprotected` carries the encapsulated key (`enc`). AAD is bound by the caller
|
|
6
|
+
and re-bound canonically over the protected header to mirror the A-ALG-BOUND
|
|
7
|
+
discipline of `wire.py`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
|
|
14
|
+
import cbor2
|
|
15
|
+
|
|
16
|
+
from hypermind.crypto import hpke
|
|
17
|
+
|
|
18
|
+
# COSE header labels (RFC 9052 §3.1)
|
|
19
|
+
_LABEL_ALG = 1
|
|
20
|
+
_LABEL_KID = 4
|
|
21
|
+
# HyperMind-private label for HPKE encapsulated key in the unprotected map.
|
|
22
|
+
_LABEL_ENC = -1000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CoseEncryptError(ValueError):
|
|
26
|
+
"""Raised on malformed or non-canonical COSE_Encrypt0 input."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _det_cbor(obj: object) -> bytes:
|
|
30
|
+
return cbor2.dumps(obj, canonical=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _kid_for_pub(recipient_pub: bytes) -> bytes:
|
|
34
|
+
return hashlib.sha256(recipient_pub).digest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _build_external_aad(protected_bytes: bytes, caller_aad: bytes) -> bytes:
|
|
38
|
+
# Bind the protected header bytes into the AEAD AAD so any tamper of the
|
|
39
|
+
# header (alg/kid) flips the tag. Mirrors `canonical_sig_input` in wire.py.
|
|
40
|
+
return _det_cbor([protected_bytes, caller_aad])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def encrypt(suite_id: int, recipient_pub: bytes, aad: bytes, plaintext: bytes) -> bytes:
|
|
44
|
+
"""Encrypt `plaintext` to `recipient_pub` under HPKE suite `suite_id`.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
suite_id: HPKE suite id (see `hpke.SUITE_*`).
|
|
48
|
+
recipient_pub: Recipient X25519 public key (32 bytes).
|
|
49
|
+
aad: Caller additional authenticated data, bound by the AEAD.
|
|
50
|
+
plaintext: Bytes to encrypt.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Canonical CBOR-encoded `COSE_Encrypt0` 3-tuple `[protected: bstr,
|
|
54
|
+
unprotected: map, ciphertext: bstr]`.
|
|
55
|
+
"""
|
|
56
|
+
protected_map: dict[int, object] = {
|
|
57
|
+
_LABEL_ALG: int(suite_id),
|
|
58
|
+
_LABEL_KID: _kid_for_pub(recipient_pub),
|
|
59
|
+
}
|
|
60
|
+
protected_bytes = _det_cbor(protected_map)
|
|
61
|
+
|
|
62
|
+
external_aad = _build_external_aad(protected_bytes, aad)
|
|
63
|
+
# HPKE `info` is empty for L3 envelope; suite_id + kid already domain-bound
|
|
64
|
+
# via the AEAD AAD.
|
|
65
|
+
enc, ct = hpke.seal(suite_id, recipient_pub, b"", external_aad, plaintext)
|
|
66
|
+
|
|
67
|
+
unprotected: dict[int, object] = {_LABEL_ENC: enc}
|
|
68
|
+
return _det_cbor([protected_bytes, unprotected, ct])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def decrypt(recipient_priv: bytes, cose_encrypt0_bytes: bytes, aad: bytes) -> bytes:
|
|
72
|
+
"""Decrypt a COSE_Encrypt0 envelope.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
recipient_priv: Recipient X25519 private key (32 bytes).
|
|
76
|
+
cose_encrypt0_bytes: Canonical CBOR output of `encrypt()`.
|
|
77
|
+
aad: Same caller AAD passed to `encrypt()`.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Decrypted plaintext bytes.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
CoseEncryptError: malformed or non-canonical input.
|
|
84
|
+
cryptography.exceptions.InvalidTag: AEAD verification failed (tampered
|
|
85
|
+
ciphertext, AAD mismatch, or wrong recipient key).
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
msg = cbor2.loads(cose_encrypt0_bytes)
|
|
89
|
+
except cbor2.CBORDecodeError as e:
|
|
90
|
+
raise CoseEncryptError(f"not valid CBOR: {e}") from e
|
|
91
|
+
|
|
92
|
+
if not isinstance(msg, list) or len(msg) != 3:
|
|
93
|
+
raise CoseEncryptError("COSE_Encrypt0 must be a 3-element array")
|
|
94
|
+
protected_bytes, unprotected, ciphertext = msg
|
|
95
|
+
if not isinstance(protected_bytes, bytes):
|
|
96
|
+
raise CoseEncryptError("protected must be bstr")
|
|
97
|
+
if not isinstance(unprotected, dict):
|
|
98
|
+
raise CoseEncryptError("unprotected must be a map")
|
|
99
|
+
if not isinstance(ciphertext, bytes):
|
|
100
|
+
raise CoseEncryptError("ciphertext must be bstr")
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
protected = cbor2.loads(protected_bytes)
|
|
104
|
+
except cbor2.CBORDecodeError as e:
|
|
105
|
+
raise CoseEncryptError(f"protected header is not valid CBOR: {e}") from e
|
|
106
|
+
if not isinstance(protected, dict):
|
|
107
|
+
raise CoseEncryptError("protected header must be a CBOR map")
|
|
108
|
+
|
|
109
|
+
# Strict canonical re-encode gate (A-ALG-BOUND parity with wire.py).
|
|
110
|
+
if _det_cbor(protected) != protected_bytes:
|
|
111
|
+
raise CoseEncryptError("non-canonical protected header encoding")
|
|
112
|
+
if _det_cbor(msg) != cose_encrypt0_bytes:
|
|
113
|
+
raise CoseEncryptError("non-canonical COSE_Encrypt0 encoding")
|
|
114
|
+
|
|
115
|
+
if _LABEL_ALG not in protected or _LABEL_KID not in protected:
|
|
116
|
+
raise CoseEncryptError("protected header missing alg or kid")
|
|
117
|
+
suite_id = protected[_LABEL_ALG]
|
|
118
|
+
if not isinstance(suite_id, int):
|
|
119
|
+
raise CoseEncryptError("alg must be int")
|
|
120
|
+
|
|
121
|
+
enc = unprotected.get(_LABEL_ENC)
|
|
122
|
+
if not isinstance(enc, bytes):
|
|
123
|
+
raise CoseEncryptError("unprotected map missing HPKE enc bstr")
|
|
124
|
+
|
|
125
|
+
external_aad = _build_external_aad(protected_bytes, aad)
|
|
126
|
+
return hpke.open(suite_id, recipient_priv, enc, b"", external_aad, ciphertext)
|
hypermind/crypto/dkg.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Pedersen distributed key generation (ROADMAP §0.2.3).
|
|
2
|
+
|
|
3
|
+
Implements the GJKR99 / Pedersen DKG protocol layer on Ed25519. The
|
|
4
|
+
output is a t-of-n share set whose group public key matches the FROST
|
|
5
|
+
trusted-dealer derivation in :mod:`hypermind.crypto.frost`, so an
|
|
6
|
+
existing FROST signer can consume DKG-derived shares without code
|
|
7
|
+
changes.
|
|
8
|
+
|
|
9
|
+
Protocol (3 rounds):
|
|
10
|
+
|
|
11
|
+
Round 1
|
|
12
|
+
Each participant *i* picks a random degree-(t-1) polynomial
|
|
13
|
+
``f_i`` with ``f_i(0)`` as their secret-share contribution.
|
|
14
|
+
Broadcasts public commitments
|
|
15
|
+
``C_i = [f_i.coeff[k] * G for k in 0..t-1]``.
|
|
16
|
+
|
|
17
|
+
Round 2
|
|
18
|
+
Sends ``s_{i→j} = f_i(j)`` privately to each *j*. Receivers verify
|
|
19
|
+
the share against ``C_i`` by checking
|
|
20
|
+
``s_{i→j} * G == sum(j**k * C_i[k] for k in range(t))``.
|
|
21
|
+
Misbehaving participants are added to a complaint set.
|
|
22
|
+
|
|
23
|
+
Round 3
|
|
24
|
+
The qualifying set ``Q`` excludes any participant who failed share
|
|
25
|
+
verification or did not deliver. Each remaining participant
|
|
26
|
+
computes their final share ``s_i = sum(f_j(i) for j in Q)`` and
|
|
27
|
+
the group public key ``Y = sum(C_j[0] for j in Q)``.
|
|
28
|
+
|
|
29
|
+
Security properties:
|
|
30
|
+
|
|
31
|
+
* No single participant ever sees the group secret.
|
|
32
|
+
* As long as at least *t* honest participants remain in *Q*, the
|
|
33
|
+
group key is a valid (t, n) Shamir share of an unbiased random
|
|
34
|
+
secret in [0, L).
|
|
35
|
+
* Malicious participants are detected by share verification — every
|
|
36
|
+
receiver can publicly prove that participant *j* sent a bad share.
|
|
37
|
+
|
|
38
|
+
This module ships the **protocol layer**. Ceremony tooling (offline
|
|
39
|
+
coordinator, paper-tape transcripts, signed round receipts) lands at
|
|
40
|
+
v0.7 with the witness-onboarding work — see ROADMAP §0.7.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import secrets
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
|
|
48
|
+
from nacl.bindings import (
|
|
49
|
+
crypto_core_ed25519_add,
|
|
50
|
+
crypto_core_ed25519_scalar_add,
|
|
51
|
+
crypto_core_ed25519_scalar_mul,
|
|
52
|
+
crypto_scalarmult_ed25519_base_noclamp,
|
|
53
|
+
crypto_scalarmult_ed25519_noclamp,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
from hypermind.crypto.frost import (
|
|
57
|
+
FrostShare,
|
|
58
|
+
L,
|
|
59
|
+
_validate_point,
|
|
60
|
+
_validate_scalar,
|
|
61
|
+
random_scalar,
|
|
62
|
+
scalar_from_int,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class ParticipantState:
|
|
68
|
+
"""Local state held by a single DKG participant.
|
|
69
|
+
|
|
70
|
+
Attributes:
|
|
71
|
+
index: 1-based participant id (matches FROST share index).
|
|
72
|
+
coefficients: List of *t* secret scalars (private; never leaves
|
|
73
|
+
the participant). ``coefficients[0]`` is the participant's
|
|
74
|
+
secret contribution to the group key.
|
|
75
|
+
commitments: List of *t* public points
|
|
76
|
+
``coefficients[k] * G``. Broadcast at end of Round 1.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
index: int
|
|
80
|
+
coefficients: tuple[bytes, ...]
|
|
81
|
+
commitments: tuple[bytes, ...]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class DkgSession:
|
|
86
|
+
"""In-process Pedersen DKG coordinator.
|
|
87
|
+
|
|
88
|
+
The session orchestrates all three rounds for ``n`` simulated
|
|
89
|
+
participants. Real deployments split this across processes; the
|
|
90
|
+
in-process simulator is suitable for tests, sandboxes, and
|
|
91
|
+
pre-ceremony rehearsal.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
threshold: int
|
|
95
|
+
participants: int
|
|
96
|
+
_states: dict[int, ParticipantState] = field(default_factory=dict)
|
|
97
|
+
# round-2 received shares: {receiver_index: {sender_index: scalar}}
|
|
98
|
+
_received: dict[int, dict[int, bytes]] = field(default_factory=dict)
|
|
99
|
+
# qualifying set after round 2 (None until round 2 completes)
|
|
100
|
+
_qualifying: set[int] | None = None
|
|
101
|
+
|
|
102
|
+
def __post_init__(self) -> None:
|
|
103
|
+
if not (1 <= self.threshold <= self.participants):
|
|
104
|
+
raise ValueError("require 1 <= threshold <= participants")
|
|
105
|
+
if self.participants < 1:
|
|
106
|
+
raise ValueError("participants must be >= 1")
|
|
107
|
+
|
|
108
|
+
# ---- round 1 ------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
def round1(self, *, rng_seed: int | None = None) -> dict[int, tuple[bytes, ...]]:
|
|
111
|
+
"""Generate every participant's polynomial + commitments.
|
|
112
|
+
|
|
113
|
+
Returns a public broadcast: ``{participant_index: commitments}``.
|
|
114
|
+
"""
|
|
115
|
+
rand = secrets.SystemRandom() if rng_seed is None else None
|
|
116
|
+
for i in range(1, self.participants + 1):
|
|
117
|
+
coeffs = tuple(random_scalar() for _ in range(self.threshold))
|
|
118
|
+
commits = tuple(crypto_scalarmult_ed25519_base_noclamp(c) for c in coeffs)
|
|
119
|
+
self._states[i] = ParticipantState(index=i, coefficients=coeffs, commitments=commits)
|
|
120
|
+
self._received[i] = {}
|
|
121
|
+
_ = rand # unused; future hook for testable RNG injection
|
|
122
|
+
return {i: s.commitments for i, s in self._states.items()}
|
|
123
|
+
|
|
124
|
+
# ---- round 2 ------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def round2(
|
|
127
|
+
self,
|
|
128
|
+
*,
|
|
129
|
+
tampered_shares: dict[tuple[int, int], bytes] | None = None,
|
|
130
|
+
) -> set[int]:
|
|
131
|
+
"""Distribute private shares and verify them.
|
|
132
|
+
|
|
133
|
+
``tampered_shares`` simulates a malicious participant: a
|
|
134
|
+
``{(sender, receiver): bad_scalar}`` map replaces the otherwise
|
|
135
|
+
correct ``f_sender(receiver)`` with ``bad_scalar``. Receivers
|
|
136
|
+
detect the tampering via the public commitments and add the
|
|
137
|
+
sender to the complaint set.
|
|
138
|
+
|
|
139
|
+
Returns the qualifying set *Q* — participants whose shares
|
|
140
|
+
verified against their commitments.
|
|
141
|
+
"""
|
|
142
|
+
if not self._states:
|
|
143
|
+
raise RuntimeError("call round1() before round2()")
|
|
144
|
+
complaints: set[int] = set()
|
|
145
|
+
tampered_shares = tampered_shares or {}
|
|
146
|
+
|
|
147
|
+
for sender_idx, sender in self._states.items():
|
|
148
|
+
for receiver_idx in range(1, self.participants + 1):
|
|
149
|
+
share = tampered_shares.get((sender_idx, receiver_idx)) or _evaluate_polynomial(
|
|
150
|
+
sender.coefficients, receiver_idx
|
|
151
|
+
)
|
|
152
|
+
# Verify share against sender's public commitments.
|
|
153
|
+
if not _verify_share(share, receiver_idx, sender.commitments):
|
|
154
|
+
complaints.add(sender_idx)
|
|
155
|
+
continue
|
|
156
|
+
self._received[receiver_idx][sender_idx] = share
|
|
157
|
+
|
|
158
|
+
qualifying = set(self._states.keys()) - complaints
|
|
159
|
+
if len(qualifying) < self.threshold:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
f"DKG aborted: qualifying set size {len(qualifying)} below "
|
|
162
|
+
f"threshold {self.threshold} after {len(complaints)} complaint(s)"
|
|
163
|
+
)
|
|
164
|
+
self._qualifying = qualifying
|
|
165
|
+
return qualifying
|
|
166
|
+
|
|
167
|
+
# ---- round 3 ------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def finalize(self) -> tuple[bytes, list[FrostShare]]:
|
|
170
|
+
"""Compute the group public key and per-participant FROST shares.
|
|
171
|
+
|
|
172
|
+
Only members of the qualifying set *Q* receive a final share.
|
|
173
|
+
The returned shares are interoperable with
|
|
174
|
+
:mod:`hypermind.crypto.frost` (same scalar/point encoding,
|
|
175
|
+
identical Lagrange reconstruction).
|
|
176
|
+
"""
|
|
177
|
+
if self._qualifying is None:
|
|
178
|
+
raise RuntimeError("call round2() before finalize()")
|
|
179
|
+
|
|
180
|
+
# Group public key Y = sum_{j in Q} C_j[0]
|
|
181
|
+
group_pk: bytes | None = None
|
|
182
|
+
for j in sorted(self._qualifying):
|
|
183
|
+
c0 = self._states[j].commitments[0]
|
|
184
|
+
group_pk = c0 if group_pk is None else crypto_core_ed25519_add(group_pk, c0)
|
|
185
|
+
assert group_pk is not None
|
|
186
|
+
_validate_point(group_pk)
|
|
187
|
+
|
|
188
|
+
shares: list[FrostShare] = []
|
|
189
|
+
for i in sorted(self._qualifying):
|
|
190
|
+
# s_i = sum_{j in Q} f_j(i)
|
|
191
|
+
acc = scalar_from_int(0)
|
|
192
|
+
for j in sorted(self._qualifying):
|
|
193
|
+
share_ji = self._received[i].get(j)
|
|
194
|
+
if share_ji is None:
|
|
195
|
+
raise RuntimeError(f"participant {i} did not receive share from {j}")
|
|
196
|
+
acc = crypto_core_ed25519_scalar_add(acc, share_ji)
|
|
197
|
+
shares.append(FrostShare(index=i, secret=acc, group_public=group_pk))
|
|
198
|
+
return group_pk, shares
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---- helpers ----------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _evaluate_polynomial(coeffs: tuple[bytes, ...], x: int) -> bytes:
|
|
205
|
+
"""Evaluate ``f(x) = sum_k coeffs[k] * x^k`` mod L."""
|
|
206
|
+
if x <= 0:
|
|
207
|
+
raise ValueError("polynomial evaluation point must be > 0")
|
|
208
|
+
x_pow = scalar_from_int(1)
|
|
209
|
+
x_step = scalar_from_int(x)
|
|
210
|
+
acc = scalar_from_int(0)
|
|
211
|
+
for k, a_k in enumerate(coeffs):
|
|
212
|
+
term = crypto_core_ed25519_scalar_mul(a_k, x_pow)
|
|
213
|
+
acc = crypto_core_ed25519_scalar_add(acc, term)
|
|
214
|
+
if k < len(coeffs) - 1:
|
|
215
|
+
x_pow = crypto_core_ed25519_scalar_mul(x_pow, x_step)
|
|
216
|
+
return acc
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _verify_share(share: bytes, receiver_idx: int, commitments: tuple[bytes, ...]) -> bool:
|
|
220
|
+
"""Check ``share * G == sum_k receiver_idx^k * commitments[k]``."""
|
|
221
|
+
try:
|
|
222
|
+
_validate_scalar(share)
|
|
223
|
+
except ValueError:
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
# LHS: share * G
|
|
227
|
+
lhs = crypto_scalarmult_ed25519_base_noclamp(share)
|
|
228
|
+
|
|
229
|
+
# RHS: sum_k (receiver_idx^k mod L) * commitments[k]
|
|
230
|
+
rhs: bytes | None = None
|
|
231
|
+
x_pow_int = 1
|
|
232
|
+
for _k, C_k in enumerate(commitments):
|
|
233
|
+
scalar = scalar_from_int(x_pow_int)
|
|
234
|
+
term = crypto_scalarmult_ed25519_noclamp(scalar, C_k)
|
|
235
|
+
rhs = term if rhs is None else crypto_core_ed25519_add(rhs, term)
|
|
236
|
+
x_pow_int = (x_pow_int * receiver_idx) % L
|
|
237
|
+
return lhs == rhs
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def run_dkg(threshold: int, participants: int) -> tuple[bytes, list[FrostShare]]:
|
|
241
|
+
"""One-shot helper: execute all three rounds with no malicious
|
|
242
|
+
participants. Returns ``(group_public_key, shares)``."""
|
|
243
|
+
session = DkgSession(threshold=threshold, participants=participants)
|
|
244
|
+
session.round1()
|
|
245
|
+
session.round2()
|
|
246
|
+
return session.finalize()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Domain-separation tags (spec/02-cryptography.md §3.2).
|
|
2
|
+
|
|
3
|
+
Every cryptographic operation prefixes its preimage with a single byte
|
|
4
|
+
tag from this enum. Verifiers MUST reject any verification where the
|
|
5
|
+
expected tag does not match. This is the WF-DOMAIN refinement lemma —
|
|
6
|
+
it prevents cross-protocol signature substitution.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
from enum import IntEnum
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DomainTag(IntEnum):
|
|
16
|
+
"""Reserved domain-separation tags."""
|
|
17
|
+
|
|
18
|
+
ENVELOPE_SIGNATURE = 0x01
|
|
19
|
+
KID = 0x02
|
|
20
|
+
RID = 0x03 # receipt id
|
|
21
|
+
AGENT_CARD_CID = 0x04
|
|
22
|
+
RECEIPT_BODY = 0x05
|
|
23
|
+
ROTATION_RECEIPT = 0x06
|
|
24
|
+
SUCCESSION = 0x07
|
|
25
|
+
STH_LEAF = 0x08
|
|
26
|
+
COUNCIL_COSIG = 0x09
|
|
27
|
+
VC_ANCHOR = 0x0A
|
|
28
|
+
PROOF_RECORD = 0x0B
|
|
29
|
+
STATE_HASH = 0x0C
|
|
30
|
+
EMBED_RESP = 0x0D
|
|
31
|
+
MENTOR_SLASH = 0x0E
|
|
32
|
+
SEAL_REVOKE = 0x0F
|
|
33
|
+
CIVIC_REPUTATION_ENTRY = 0x10
|
|
34
|
+
MEMORIAL_BUMP = 0x11
|
|
35
|
+
# 0x12–0x17 — TAL (room_define, room_join, vouch, vouch_revoke,
|
|
36
|
+
# reputation_attestation, trust_query/reply)
|
|
37
|
+
TAL_ROOM_DEFINE = 0x12
|
|
38
|
+
TAL_ROOM_JOIN = 0x13
|
|
39
|
+
TAL_VOUCH = 0x14
|
|
40
|
+
TAL_VOUCH_REVOKE = 0x15
|
|
41
|
+
TAL_REPUTATION_ATTESTATION = 0x16
|
|
42
|
+
TAL_TRUST_QUERY = 0x17
|
|
43
|
+
# P2-16: KNOWS_DELTA gossip message type
|
|
44
|
+
KNOWS_DELTA = 0x18
|
|
45
|
+
# 0x19–0xFF reserved
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def domain_separated_hash(tag: DomainTag, payload: bytes) -> bytes:
|
|
49
|
+
"""SHA-256 over `tag || payload`. The single canonical hash op."""
|
|
50
|
+
h = hashlib.sha256()
|
|
51
|
+
h.update(bytes([int(tag)]))
|
|
52
|
+
h.update(payload)
|
|
53
|
+
return h.digest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def reject_unallocated(tag_byte: int) -> None:
|
|
57
|
+
"""Verifier MUST reject envelopes whose first byte is in 0x19..0xFF."""
|
|
58
|
+
if tag_byte > 0x18:
|
|
59
|
+
raise ValueError(f"unallocated domain tag 0x{tag_byte:02X}; reject envelope")
|