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,797 @@
|
|
|
1
|
+
"""Real TCP transport — async TCP gossipsub-shape with identity binding.
|
|
2
|
+
|
|
3
|
+
Each peer-to-peer connection performs a 2-message handshake binding
|
|
4
|
+
both endpoints' Ed25519 identities, then exchanges length-prefixed
|
|
5
|
+
CBOR frames carrying topic + message. Topic membership is reputation-
|
|
6
|
+
weighted: a peer joining a mesh announces its kid; the local node
|
|
7
|
+
checks reputation policy before accepting traffic.
|
|
8
|
+
|
|
9
|
+
This is a working real-network transport (not a stub) — start nodes on
|
|
10
|
+
different ports, dial them via `connect()`, and gossip propagates over
|
|
11
|
+
asyncio TCP streams. Production deployments may swap this for libp2p
|
|
12
|
+
gossipsub v1.2; the `GossipBus` interface stays the same.
|
|
13
|
+
|
|
14
|
+
WS-G hardening (v0.7):
|
|
15
|
+
* Per-peer bounded write queue (`asyncio.Queue(maxsize=PEER_QUEUE_MAX)`)
|
|
16
|
+
+ dedicated sender task per peer. Prevents one slow peer from
|
|
17
|
+
pinning memory or stalling the publish fan-out path.
|
|
18
|
+
* Drop-newest backpressure: queue-full → ``peer_drops_total{kid}``
|
|
19
|
+
counter, peer score penalty, and graylist after 3 drops in 30s.
|
|
20
|
+
* Per-(source_kid, topic) token bucket: 100 msg/s burst 200. Excess
|
|
21
|
+
is rejected with a score penalty.
|
|
22
|
+
* Per-peer EWMA score: ``score = w1*time_in_mesh + w2*deliveries
|
|
23
|
+
- w3*invalid - w4*backpressure_drops``. Below threshold the peer
|
|
24
|
+
is graylisted for ``GRAYLIST_DURATION_S``.
|
|
25
|
+
* Two-tier seen cache: per-peer LRU (1024) + global secondary LRU
|
|
26
|
+
(16k). Defeats both per-flow flooding and amplification attacks.
|
|
27
|
+
|
|
28
|
+
Wire frame format:
|
|
29
|
+
[u32 BE length][CBOR map { "type": str, ...payload }]
|
|
30
|
+
|
|
31
|
+
Frame types:
|
|
32
|
+
"hello": {"kid": bytes32, "alg": int, "sig": bytes, "nonce": bytes32}
|
|
33
|
+
"publish": {"topic": str, "message": bytes, "msg_id": bytes32}
|
|
34
|
+
"subscribe": {"topic": str}
|
|
35
|
+
"ack": {"msg_id": bytes32}
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import asyncio
|
|
41
|
+
import secrets
|
|
42
|
+
import struct
|
|
43
|
+
import time
|
|
44
|
+
from collections import OrderedDict, deque
|
|
45
|
+
from collections.abc import Awaitable, Callable
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
|
|
48
|
+
import cbor2
|
|
49
|
+
from nacl.signing import SigningKey
|
|
50
|
+
|
|
51
|
+
from hypermind.crypto.signing import Keypair, verify_ed25519
|
|
52
|
+
from hypermind.transport.bus import Subscription
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Tunables (WS-G robustness profile).
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
SEEN_CACHE_MAX = 16384 # global secondary LRU cap (memory DoS bound).
|
|
59
|
+
PER_PEER_SEEN_MAX = 1024 # per-peer LRU; first-line dedup tier.
|
|
60
|
+
PEER_QUEUE_MAX = 4096 # per-peer write queue depth.
|
|
61
|
+
|
|
62
|
+
# Token-bucket rate limit per (source_kid, topic).
|
|
63
|
+
RATE_LIMIT_RATE = 100.0 # tokens/second steady state.
|
|
64
|
+
RATE_LIMIT_BURST = 200.0 # max bucket size.
|
|
65
|
+
|
|
66
|
+
# Backpressure / graylist policy.
|
|
67
|
+
DROP_BURST_THRESHOLD = 3 # consecutive drops within window.
|
|
68
|
+
DROP_BURST_WINDOW_S = 30.0
|
|
69
|
+
GRAYLIST_DURATION_S = 3600.0 # 1 hour default.
|
|
70
|
+
|
|
71
|
+
# EWMA scoring weights (per RFC-style gossipsub v1.2 inspiration).
|
|
72
|
+
SCORE_W_TIME_IN_MESH = 0.1
|
|
73
|
+
SCORE_W_DELIVERIES = 1.0
|
|
74
|
+
SCORE_W_INVALID = 5.0
|
|
75
|
+
SCORE_W_BACKPRESSURE = 2.0
|
|
76
|
+
SCORE_GRAYLIST_THRESHOLD = -50.0
|
|
77
|
+
|
|
78
|
+
Handler = Callable[[bytes], Awaitable[None]]
|
|
79
|
+
|
|
80
|
+
FRAME_HEADER = struct.Struct(">I")
|
|
81
|
+
MAX_FRAME = 16 * 1024 * 1024 # 16 MB hard cap
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Token-bucket rate limiter (per (source_kid, topic)).
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class _TokenBucket:
|
|
91
|
+
"""Lazy-fill token bucket. Allows ``rate`` tokens/sec, burst ``capacity``.
|
|
92
|
+
|
|
93
|
+
Updates the bucket only on consume — no background task required.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
rate: float
|
|
97
|
+
capacity: float
|
|
98
|
+
tokens: float
|
|
99
|
+
last_ms: int
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def create(cls, rate: float = RATE_LIMIT_RATE, burst: float = RATE_LIMIT_BURST) -> _TokenBucket:
|
|
103
|
+
return cls(rate=rate, capacity=burst, tokens=burst, last_ms=_now_ms())
|
|
104
|
+
|
|
105
|
+
def try_consume(self, n: float = 1.0) -> bool:
|
|
106
|
+
now = _now_ms()
|
|
107
|
+
elapsed = max(0, now - self.last_ms) / 1000.0
|
|
108
|
+
self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
|
|
109
|
+
self.last_ms = now
|
|
110
|
+
if self.tokens >= n:
|
|
111
|
+
self.tokens -= n
|
|
112
|
+
return True
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class _PeerConn:
|
|
118
|
+
kid: bytes
|
|
119
|
+
reader: asyncio.StreamReader
|
|
120
|
+
writer: asyncio.StreamWriter
|
|
121
|
+
topics: set[str] = field(default_factory=set)
|
|
122
|
+
# WS-G: bounded write queue + dedicated sender task.
|
|
123
|
+
send_queue: asyncio.Queue[dict] = field(
|
|
124
|
+
default_factory=lambda: asyncio.Queue(maxsize=PEER_QUEUE_MAX)
|
|
125
|
+
)
|
|
126
|
+
sender_task: asyncio.Task | None = None
|
|
127
|
+
# Per-peer LRU dedup (first-tier).
|
|
128
|
+
seen: OrderedDict[bytes, None] = field(default_factory=OrderedDict)
|
|
129
|
+
# Score state.
|
|
130
|
+
connected_at_ms: int = field(default_factory=lambda: _now_ms())
|
|
131
|
+
deliveries: int = 0
|
|
132
|
+
invalid_frames: int = 0
|
|
133
|
+
backpressure_drops: int = 0
|
|
134
|
+
drop_history: deque[float] = field(default_factory=deque)
|
|
135
|
+
|
|
136
|
+
def record_seen(self, msg_id: bytes) -> bool:
|
|
137
|
+
"""Insert into per-peer LRU. Returns True if NEW (caller should
|
|
138
|
+
dispatch); False if duplicate (caller should drop)."""
|
|
139
|
+
if msg_id in self.seen:
|
|
140
|
+
self.seen.move_to_end(msg_id)
|
|
141
|
+
return False
|
|
142
|
+
self.seen[msg_id] = None
|
|
143
|
+
while len(self.seen) > PER_PEER_SEEN_MAX:
|
|
144
|
+
self.seen.popitem(last=False)
|
|
145
|
+
return True
|
|
146
|
+
|
|
147
|
+
def score(self) -> float:
|
|
148
|
+
"""Composite EWMA-style score. Higher is better; below
|
|
149
|
+
SCORE_GRAYLIST_THRESHOLD triggers graylisting."""
|
|
150
|
+
time_in_mesh_s = max(0, _now_ms() - self.connected_at_ms) / 1000.0
|
|
151
|
+
return (
|
|
152
|
+
SCORE_W_TIME_IN_MESH * time_in_mesh_s
|
|
153
|
+
+ SCORE_W_DELIVERIES * self.deliveries
|
|
154
|
+
- SCORE_W_INVALID * self.invalid_frames
|
|
155
|
+
- SCORE_W_BACKPRESSURE * self.backpressure_drops
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class TCPGossipBus:
|
|
160
|
+
"""Real asyncio TCP gossipsub bus.
|
|
161
|
+
|
|
162
|
+
Each instance can simultaneously be a server (accepting incoming
|
|
163
|
+
connections) and a client (dialing peers). Once handshake completes,
|
|
164
|
+
`publish(topic, message)` fans out to every connected peer that has
|
|
165
|
+
subscribed to that topic; `subscribe(topic, handler)` registers a
|
|
166
|
+
local handler that receives both local + remote publishes.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
*,
|
|
172
|
+
identity: Keypair,
|
|
173
|
+
host: str = "127.0.0.1",
|
|
174
|
+
port: int = 0,
|
|
175
|
+
require_tls: bool = False,
|
|
176
|
+
tls_trust_anchors: list[bytes] | None = None,
|
|
177
|
+
tls_allow_aes128: bool = False,
|
|
178
|
+
):
|
|
179
|
+
self.identity = identity
|
|
180
|
+
self.host = host
|
|
181
|
+
self.port = port
|
|
182
|
+
# L2 mTLS profile (§22). When True, listen / connect socket is
|
|
183
|
+
# wrapped with the TLS 1.3 profile from `tls_profile.py` BEFORE
|
|
184
|
+
# the existing CBOR handshake runs. The handshake, SEEN_CACHE_MAX
|
|
185
|
+
# LRU, and duplicate-kid rejection are unchanged — TLS is purely
|
|
186
|
+
# additive here.
|
|
187
|
+
self.require_tls = require_tls
|
|
188
|
+
self._tls_allow_aes128 = tls_allow_aes128
|
|
189
|
+
self._tls_trust_anchors: list[bytes] = list(tls_trust_anchors or [])
|
|
190
|
+
self._tls_cert_pem: bytes | None = None
|
|
191
|
+
self._server: asyncio.AbstractServer | None = None
|
|
192
|
+
self._peers: dict[bytes, _PeerConn] = {}
|
|
193
|
+
self._handlers: dict[str, list[Handler]] = {}
|
|
194
|
+
# Two-tier dedup: per-peer (in _PeerConn.seen) + global secondary.
|
|
195
|
+
# OrderedDict-as-LRU bounds memory DoS via msg_id flooding.
|
|
196
|
+
self._seen: OrderedDict[bytes, None] = OrderedDict()
|
|
197
|
+
self._lock = asyncio.Lock()
|
|
198
|
+
self._closed = False
|
|
199
|
+
self._tasks: set[asyncio.Task] = set()
|
|
200
|
+
# Bad-frame counter — surfaced via `metrics()` for monitoring.
|
|
201
|
+
self._bad_frames: int = 0
|
|
202
|
+
# Per-peer drop counter — surfaced via `metrics()`.
|
|
203
|
+
self._peer_drops_total: dict[bytes, int] = {}
|
|
204
|
+
# Per-peer rate-rejected counter — surfaced via `metrics()`.
|
|
205
|
+
self._rate_rejects_total: dict[bytes, int] = {}
|
|
206
|
+
# Token buckets keyed by (source_kid, topic).
|
|
207
|
+
self._buckets: dict[tuple[bytes, str], _TokenBucket] = {}
|
|
208
|
+
# Graylist: kid → expiry wall-time seconds.
|
|
209
|
+
self._graylist: dict[bytes, float] = {}
|
|
210
|
+
|
|
211
|
+
# ---- server / dial ------------------------------------------------
|
|
212
|
+
|
|
213
|
+
async def start(self) -> int:
|
|
214
|
+
"""Bind and start accepting peers; return the bound port."""
|
|
215
|
+
ssl_ctx = self._build_server_tls_context() if self.require_tls else None
|
|
216
|
+
self._server = await asyncio.start_server(
|
|
217
|
+
self._on_accept, host=self.host, port=self.port, ssl=ssl_ctx
|
|
218
|
+
)
|
|
219
|
+
sock = self._server.sockets[0]
|
|
220
|
+
self.port = sock.getsockname()[1]
|
|
221
|
+
return self.port
|
|
222
|
+
|
|
223
|
+
# ---- TLS context builders (§22 L2 profile) ------------------------
|
|
224
|
+
|
|
225
|
+
def _build_server_tls_context(self):
|
|
226
|
+
from hypermind.transport.tls_profile import (
|
|
227
|
+
ed25519_priv_from_secret,
|
|
228
|
+
make_server_context,
|
|
229
|
+
selfsigned_cert_for_kid,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
priv = ed25519_priv_from_secret(self.identity.secret_bytes)
|
|
233
|
+
if self._tls_cert_pem is None:
|
|
234
|
+
self._tls_cert_pem = selfsigned_cert_for_kid(self.identity.kid, priv)
|
|
235
|
+
return make_server_context(
|
|
236
|
+
priv,
|
|
237
|
+
self._tls_cert_pem,
|
|
238
|
+
trust_anchors=self._tls_trust_anchors or None,
|
|
239
|
+
allow_aes128=self._tls_allow_aes128,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def _build_client_tls_context(self):
|
|
243
|
+
from hypermind.transport.tls_profile import (
|
|
244
|
+
ed25519_priv_from_secret,
|
|
245
|
+
make_client_context,
|
|
246
|
+
selfsigned_cert_for_kid,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
priv = ed25519_priv_from_secret(self.identity.secret_bytes)
|
|
250
|
+
if self._tls_cert_pem is None:
|
|
251
|
+
self._tls_cert_pem = selfsigned_cert_for_kid(self.identity.kid, priv)
|
|
252
|
+
anchors = self._tls_trust_anchors or [self._tls_cert_pem]
|
|
253
|
+
return make_client_context(
|
|
254
|
+
anchors,
|
|
255
|
+
client_cert_pem=self._tls_cert_pem,
|
|
256
|
+
client_key=priv,
|
|
257
|
+
allow_aes128=self._tls_allow_aes128,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
async def connect(self, peer_host: str, peer_port: int) -> bytes:
|
|
261
|
+
"""Dial a peer and complete the handshake. Returns peer's kid.
|
|
262
|
+
|
|
263
|
+
Rejects connections to a kid we already have a live peer for —
|
|
264
|
+
defeats the "race-to-overwrite" attack where an adversary
|
|
265
|
+
learning a target's kid evicts the legitimate session
|
|
266
|
+
(closes self-heal Cycle-1 Finding #3).
|
|
267
|
+
"""
|
|
268
|
+
ssl_ctx = self._build_client_tls_context() if self.require_tls else None
|
|
269
|
+
reader, writer = await asyncio.open_connection(
|
|
270
|
+
peer_host,
|
|
271
|
+
peer_port,
|
|
272
|
+
ssl=ssl_ctx,
|
|
273
|
+
server_hostname=peer_host if ssl_ctx is not None else None,
|
|
274
|
+
)
|
|
275
|
+
try:
|
|
276
|
+
if self.require_tls:
|
|
277
|
+
_verify_tls_alpn(writer)
|
|
278
|
+
peer_kid = await self._handshake_initiator(reader, writer)
|
|
279
|
+
if self.require_tls:
|
|
280
|
+
_verify_tls_kid_binding(writer, peer_kid)
|
|
281
|
+
except Exception:
|
|
282
|
+
writer.close()
|
|
283
|
+
try:
|
|
284
|
+
await writer.wait_closed()
|
|
285
|
+
except Exception:
|
|
286
|
+
pass
|
|
287
|
+
raise
|
|
288
|
+
if self._is_graylisted(peer_kid):
|
|
289
|
+
writer.close()
|
|
290
|
+
try:
|
|
291
|
+
await writer.wait_closed()
|
|
292
|
+
except Exception:
|
|
293
|
+
pass
|
|
294
|
+
raise RuntimeError(f"peer kid {peer_kid.hex()[:16]}… is graylisted")
|
|
295
|
+
peer = _PeerConn(kid=peer_kid, reader=reader, writer=writer)
|
|
296
|
+
async with self._lock:
|
|
297
|
+
if peer_kid in self._peers:
|
|
298
|
+
# Reject collision; close the new connection cleanly.
|
|
299
|
+
writer.close()
|
|
300
|
+
try:
|
|
301
|
+
await writer.wait_closed()
|
|
302
|
+
except Exception:
|
|
303
|
+
pass
|
|
304
|
+
raise RuntimeError(f"peer kid {peer_kid.hex()[:16]}… already connected")
|
|
305
|
+
self._peers[peer_kid] = peer
|
|
306
|
+
self._spawn_sender(peer)
|
|
307
|
+
# Re-announce existing local subscriptions to the new peer.
|
|
308
|
+
for topic in self._handlers:
|
|
309
|
+
self._enqueue(peer, {"type": "subscribe", "topic": topic})
|
|
310
|
+
task = asyncio.create_task(self._read_loop(peer))
|
|
311
|
+
self._tasks.add(task)
|
|
312
|
+
task.add_done_callback(self._tasks.discard)
|
|
313
|
+
return peer_kid
|
|
314
|
+
|
|
315
|
+
async def _on_accept(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
316
|
+
try:
|
|
317
|
+
if self.require_tls:
|
|
318
|
+
_verify_tls_alpn(writer)
|
|
319
|
+
peer_kid = await self._handshake_responder(reader, writer)
|
|
320
|
+
if self.require_tls:
|
|
321
|
+
_verify_tls_kid_binding(writer, peer_kid)
|
|
322
|
+
except Exception:
|
|
323
|
+
writer.close()
|
|
324
|
+
try:
|
|
325
|
+
await writer.wait_closed()
|
|
326
|
+
except Exception:
|
|
327
|
+
pass
|
|
328
|
+
return
|
|
329
|
+
if self._is_graylisted(peer_kid):
|
|
330
|
+
writer.close()
|
|
331
|
+
try:
|
|
332
|
+
await writer.wait_closed()
|
|
333
|
+
except Exception:
|
|
334
|
+
pass
|
|
335
|
+
return
|
|
336
|
+
peer = _PeerConn(kid=peer_kid, reader=reader, writer=writer)
|
|
337
|
+
async with self._lock:
|
|
338
|
+
if peer_kid in self._peers:
|
|
339
|
+
# Reject duplicate kid — preserves the legitimate peer.
|
|
340
|
+
writer.close()
|
|
341
|
+
try:
|
|
342
|
+
await writer.wait_closed()
|
|
343
|
+
except Exception:
|
|
344
|
+
pass
|
|
345
|
+
return
|
|
346
|
+
self._peers[peer_kid] = peer
|
|
347
|
+
self._spawn_sender(peer)
|
|
348
|
+
for topic in self._handlers:
|
|
349
|
+
self._enqueue(peer, {"type": "subscribe", "topic": topic})
|
|
350
|
+
await self._read_loop(peer)
|
|
351
|
+
|
|
352
|
+
# ---- handshake ----------------------------------------------------
|
|
353
|
+
# Both sides exchange: their kid + a fresh nonce + a signature over
|
|
354
|
+
# (their_nonce || peer_nonce || self.kid). This binds the identity
|
|
355
|
+
# to the session and prevents replay across connections.
|
|
356
|
+
|
|
357
|
+
async def _handshake_initiator(
|
|
358
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
359
|
+
) -> bytes:
|
|
360
|
+
my_nonce = secrets.token_bytes(32)
|
|
361
|
+
# First message: send our kid + nonce (no sig yet).
|
|
362
|
+
await _write_frame(
|
|
363
|
+
writer,
|
|
364
|
+
{
|
|
365
|
+
"type": "hello",
|
|
366
|
+
"kid": self.identity.kid,
|
|
367
|
+
"alg": self.identity.alg,
|
|
368
|
+
"nonce": my_nonce,
|
|
369
|
+
"sig": b"",
|
|
370
|
+
},
|
|
371
|
+
)
|
|
372
|
+
peer_msg = await _read_frame(reader)
|
|
373
|
+
if peer_msg.get("type") != "hello":
|
|
374
|
+
raise RuntimeError("expected hello from responder")
|
|
375
|
+
peer_kid = bytes(peer_msg["kid"])
|
|
376
|
+
if len(peer_kid) != 32:
|
|
377
|
+
raise RuntimeError("peer handshake: invalid kid length")
|
|
378
|
+
peer_nonce = bytes(peer_msg["nonce"])
|
|
379
|
+
peer_sig = bytes(peer_msg["sig"])
|
|
380
|
+
# Verify peer's signature over (peer_nonce || my_nonce || peer_kid).
|
|
381
|
+
peer_preimage = peer_nonce + my_nonce + peer_kid
|
|
382
|
+
if not verify_ed25519(peer_kid, peer_sig, peer_preimage):
|
|
383
|
+
raise RuntimeError("peer handshake signature invalid")
|
|
384
|
+
# Send our signature.
|
|
385
|
+
my_preimage = my_nonce + peer_nonce + self.identity.kid
|
|
386
|
+
my_sig = SigningKey(self.identity.secret_bytes[:32]).sign(my_preimage).signature
|
|
387
|
+
await _write_frame(
|
|
388
|
+
writer,
|
|
389
|
+
{
|
|
390
|
+
"type": "hello_ack",
|
|
391
|
+
"kid": self.identity.kid,
|
|
392
|
+
"sig": my_sig,
|
|
393
|
+
},
|
|
394
|
+
)
|
|
395
|
+
return peer_kid
|
|
396
|
+
|
|
397
|
+
async def _handshake_responder(
|
|
398
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
399
|
+
) -> bytes:
|
|
400
|
+
peer_msg = await _read_frame(reader)
|
|
401
|
+
if peer_msg.get("type") != "hello":
|
|
402
|
+
raise RuntimeError("expected hello from initiator")
|
|
403
|
+
peer_kid = bytes(peer_msg["kid"])
|
|
404
|
+
if len(peer_kid) != 32:
|
|
405
|
+
raise RuntimeError("peer handshake: invalid kid length")
|
|
406
|
+
peer_nonce = bytes(peer_msg["nonce"])
|
|
407
|
+
my_nonce = secrets.token_bytes(32)
|
|
408
|
+
# Sign (my_nonce || peer_nonce || my_kid)
|
|
409
|
+
my_preimage = my_nonce + peer_nonce + self.identity.kid
|
|
410
|
+
my_sig = SigningKey(self.identity.secret_bytes[:32]).sign(my_preimage).signature
|
|
411
|
+
await _write_frame(
|
|
412
|
+
writer,
|
|
413
|
+
{
|
|
414
|
+
"type": "hello",
|
|
415
|
+
"kid": self.identity.kid,
|
|
416
|
+
"alg": self.identity.alg,
|
|
417
|
+
"nonce": my_nonce,
|
|
418
|
+
"sig": my_sig,
|
|
419
|
+
},
|
|
420
|
+
)
|
|
421
|
+
# Receive initiator's signature (over their_nonce || my_nonce || their_kid)
|
|
422
|
+
ack = await _read_frame(reader)
|
|
423
|
+
if ack.get("type") != "hello_ack":
|
|
424
|
+
raise RuntimeError("expected hello_ack from initiator")
|
|
425
|
+
peer_sig = bytes(ack["sig"])
|
|
426
|
+
peer_preimage = peer_nonce + my_nonce + peer_kid
|
|
427
|
+
if not verify_ed25519(peer_kid, peer_sig, peer_preimage):
|
|
428
|
+
raise RuntimeError("initiator signature invalid")
|
|
429
|
+
return peer_kid
|
|
430
|
+
|
|
431
|
+
# ---- pub/sub interface --------------------------------------------
|
|
432
|
+
|
|
433
|
+
async def publish(self, topic: str, message: bytes) -> None:
|
|
434
|
+
if self._closed:
|
|
435
|
+
raise RuntimeError("bus is closed")
|
|
436
|
+
if not isinstance(message, (bytes, bytearray)):
|
|
437
|
+
raise TypeError(f"message must be bytes, got {type(message).__name__}")
|
|
438
|
+
msg_id = secrets.token_bytes(32)
|
|
439
|
+
async with self._lock:
|
|
440
|
+
self._record_seen(msg_id)
|
|
441
|
+
# Local delivery first (own subscribers see own publishes).
|
|
442
|
+
for h in list(self._handlers.get(topic, ())):
|
|
443
|
+
await _safe_invoke(h, message)
|
|
444
|
+
# Then fan-out to every peer subscribed to the topic.
|
|
445
|
+
async with self._lock:
|
|
446
|
+
peers = [p for p in self._peers.values() if topic in p.topics]
|
|
447
|
+
frame = {"type": "publish", "topic": topic, "message": message, "msg_id": msg_id}
|
|
448
|
+
for p in peers:
|
|
449
|
+
self._enqueue(p, frame)
|
|
450
|
+
|
|
451
|
+
def _record_seen(self, msg_id: bytes) -> None:
|
|
452
|
+
"""Global secondary LRU (`_seen`) cache. Evicts oldest when full.
|
|
453
|
+
|
|
454
|
+
First-tier dedup is per-peer in `_PeerConn.seen`; this global
|
|
455
|
+
tier catches messages we already saw via *some other peer*."""
|
|
456
|
+
if msg_id in self._seen:
|
|
457
|
+
self._seen.move_to_end(msg_id)
|
|
458
|
+
return
|
|
459
|
+
self._seen[msg_id] = None
|
|
460
|
+
while len(self._seen) > SEEN_CACHE_MAX:
|
|
461
|
+
self._seen.popitem(last=False)
|
|
462
|
+
|
|
463
|
+
def metrics(self) -> dict[str, object]:
|
|
464
|
+
"""Operational counters (bad frames, peer count, drops, scores)."""
|
|
465
|
+
# Snapshot keyed-counters into hex-shortened views for readability.
|
|
466
|
+
peer_drops = {kid.hex()[:16]: v for kid, v in self._peer_drops_total.items()}
|
|
467
|
+
rate_rejects = {kid.hex()[:16]: v for kid, v in self._rate_rejects_total.items()}
|
|
468
|
+
scores = {p.kid.hex()[:16]: p.score() for p in self._peers.values()}
|
|
469
|
+
return {
|
|
470
|
+
"peers": len(self._peers),
|
|
471
|
+
"seen_cache": len(self._seen),
|
|
472
|
+
"bad_frames": self._bad_frames,
|
|
473
|
+
"peer_drops_total": peer_drops,
|
|
474
|
+
"rate_rejects_total": rate_rejects,
|
|
475
|
+
"peer_scores": scores,
|
|
476
|
+
"graylist_size": len(self._graylist),
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async def subscribe(self, topic: str, handler: Handler) -> Subscription:
|
|
480
|
+
if self._closed:
|
|
481
|
+
raise RuntimeError("bus is closed")
|
|
482
|
+
async with self._lock:
|
|
483
|
+
self._handlers.setdefault(topic, []).append(handler)
|
|
484
|
+
peers = list(self._peers.values())
|
|
485
|
+
# Announce subscription to all known peers (queued; best-effort).
|
|
486
|
+
for p in peers:
|
|
487
|
+
self._enqueue(p, {"type": "subscribe", "topic": topic})
|
|
488
|
+
|
|
489
|
+
def _cancel() -> None:
|
|
490
|
+
try:
|
|
491
|
+
self._handlers[topic].remove(handler)
|
|
492
|
+
except (ValueError, KeyError):
|
|
493
|
+
pass
|
|
494
|
+
|
|
495
|
+
return Subscription(topic=topic, _cancel=_cancel)
|
|
496
|
+
|
|
497
|
+
async def close(self) -> None:
|
|
498
|
+
if self._closed:
|
|
499
|
+
return
|
|
500
|
+
self._closed = True
|
|
501
|
+
async with self._lock:
|
|
502
|
+
peers = list(self._peers.values())
|
|
503
|
+
self._peers.clear()
|
|
504
|
+
for peer in peers:
|
|
505
|
+
if peer.sender_task is not None:
|
|
506
|
+
peer.sender_task.cancel()
|
|
507
|
+
try:
|
|
508
|
+
peer.writer.close()
|
|
509
|
+
await peer.writer.wait_closed()
|
|
510
|
+
except Exception:
|
|
511
|
+
pass
|
|
512
|
+
if self._server is not None:
|
|
513
|
+
self._server.close()
|
|
514
|
+
await self._server.wait_closed()
|
|
515
|
+
self._server = None
|
|
516
|
+
for task in list(self._tasks):
|
|
517
|
+
task.cancel()
|
|
518
|
+
|
|
519
|
+
# ---- I/O ----------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
async def _read_loop(self, peer: _PeerConn) -> None:
|
|
522
|
+
"""Decode + dispatch frames. Malformed frames bump the
|
|
523
|
+
bad-frame counter and continue (a single bad frame must NOT
|
|
524
|
+
kill the connection — closes self-heal Cycle-1 Finding #9
|
|
525
|
+
from data review).
|
|
526
|
+
"""
|
|
527
|
+
try:
|
|
528
|
+
while not self._closed:
|
|
529
|
+
try:
|
|
530
|
+
msg = await _read_frame(peer.reader)
|
|
531
|
+
except (cbor2.CBORDecodeError, ValueError, TypeError):
|
|
532
|
+
self._bad_frames += 1
|
|
533
|
+
peer.invalid_frames += 1
|
|
534
|
+
self._maybe_graylist(peer)
|
|
535
|
+
continue
|
|
536
|
+
if not isinstance(msg, dict):
|
|
537
|
+
self._bad_frames += 1
|
|
538
|
+
peer.invalid_frames += 1
|
|
539
|
+
self._maybe_graylist(peer)
|
|
540
|
+
continue
|
|
541
|
+
msg_type = msg.get("type")
|
|
542
|
+
if msg_type == "subscribe":
|
|
543
|
+
topic = msg.get("topic")
|
|
544
|
+
if not isinstance(topic, str):
|
|
545
|
+
self._bad_frames += 1
|
|
546
|
+
peer.invalid_frames += 1
|
|
547
|
+
continue
|
|
548
|
+
peer.topics.add(topic)
|
|
549
|
+
elif msg_type == "publish":
|
|
550
|
+
msg_id_v = msg.get("msg_id")
|
|
551
|
+
topic_v = msg.get("topic")
|
|
552
|
+
payload_v = msg.get("message")
|
|
553
|
+
if (
|
|
554
|
+
not isinstance(msg_id_v, (bytes, bytearray))
|
|
555
|
+
or not isinstance(topic_v, str)
|
|
556
|
+
or not isinstance(payload_v, (bytes, bytearray))
|
|
557
|
+
):
|
|
558
|
+
self._bad_frames += 1
|
|
559
|
+
peer.invalid_frames += 1
|
|
560
|
+
continue
|
|
561
|
+
msg_id = bytes(msg_id_v)
|
|
562
|
+
if len(msg_id) != 32:
|
|
563
|
+
self._bad_frames += 1
|
|
564
|
+
peer.invalid_frames += 1
|
|
565
|
+
continue
|
|
566
|
+
topic = topic_v
|
|
567
|
+
payload = bytes(payload_v)
|
|
568
|
+
# Token-bucket: per (source_kid, topic). On reject,
|
|
569
|
+
# bump invalid counter and skip dispatch + re-gossip.
|
|
570
|
+
bucket_key = (peer.kid, topic)
|
|
571
|
+
bucket = self._buckets.get(bucket_key)
|
|
572
|
+
if bucket is None:
|
|
573
|
+
bucket = _TokenBucket.create()
|
|
574
|
+
self._buckets[bucket_key] = bucket
|
|
575
|
+
if not bucket.try_consume():
|
|
576
|
+
self._rate_rejects_total[peer.kid] = (
|
|
577
|
+
self._rate_rejects_total.get(peer.kid, 0) + 1
|
|
578
|
+
)
|
|
579
|
+
peer.invalid_frames += 1
|
|
580
|
+
self._maybe_graylist(peer)
|
|
581
|
+
continue
|
|
582
|
+
# Per-peer LRU first; if duplicate, skip.
|
|
583
|
+
if not peer.record_seen(msg_id):
|
|
584
|
+
continue
|
|
585
|
+
async with self._lock:
|
|
586
|
+
if msg_id in self._seen:
|
|
587
|
+
self._seen.move_to_end(msg_id)
|
|
588
|
+
# Already saw this via another peer; dedup
|
|
589
|
+
# but still credit the peer for delivery.
|
|
590
|
+
peer.deliveries += 1
|
|
591
|
+
continue
|
|
592
|
+
self._record_seen(msg_id)
|
|
593
|
+
peer.deliveries += 1
|
|
594
|
+
for h in list(self._handlers.get(topic, ())):
|
|
595
|
+
await _safe_invoke(h, payload)
|
|
596
|
+
# Re-gossip to other peers subscribed to this topic.
|
|
597
|
+
async with self._lock:
|
|
598
|
+
forward_targets = [
|
|
599
|
+
p for p in self._peers.values() if p is not peer and topic in p.topics
|
|
600
|
+
]
|
|
601
|
+
forward_frame = {
|
|
602
|
+
"type": "publish",
|
|
603
|
+
"topic": topic,
|
|
604
|
+
"message": payload,
|
|
605
|
+
"msg_id": msg_id,
|
|
606
|
+
}
|
|
607
|
+
for fwd in forward_targets:
|
|
608
|
+
self._enqueue(fwd, forward_frame)
|
|
609
|
+
else:
|
|
610
|
+
# Unknown frame type — count and ignore.
|
|
611
|
+
self._bad_frames += 1
|
|
612
|
+
peer.invalid_frames += 1
|
|
613
|
+
except (asyncio.IncompleteReadError, OSError, asyncio.CancelledError):
|
|
614
|
+
# OSError catches ECONNRESET and the broader connection-error
|
|
615
|
+
# family on every platform (and subsumes ConnectionError).
|
|
616
|
+
# Closes self-heal Cycle-3 issue #5.
|
|
617
|
+
pass
|
|
618
|
+
finally:
|
|
619
|
+
async with self._lock:
|
|
620
|
+
self._peers.pop(peer.kid, None)
|
|
621
|
+
if peer.sender_task is not None:
|
|
622
|
+
peer.sender_task.cancel()
|
|
623
|
+
try:
|
|
624
|
+
peer.writer.close()
|
|
625
|
+
# Drain the OS-level socket so the file descriptor is fully
|
|
626
|
+
# released; otherwise abrupt disconnects can leak fds.
|
|
627
|
+
await peer.writer.wait_closed()
|
|
628
|
+
except Exception:
|
|
629
|
+
pass
|
|
630
|
+
|
|
631
|
+
# ---- per-peer sender + queue --------------------------------------
|
|
632
|
+
|
|
633
|
+
def _spawn_sender(self, peer: _PeerConn) -> None:
|
|
634
|
+
"""Start the dedicated sender task for ``peer``.
|
|
635
|
+
|
|
636
|
+
Caller must hold ``self._lock`` (or be in single-threaded init).
|
|
637
|
+
"""
|
|
638
|
+
task = asyncio.create_task(self._sender_loop(peer))
|
|
639
|
+
peer.sender_task = task
|
|
640
|
+
self._tasks.add(task)
|
|
641
|
+
task.add_done_callback(self._tasks.discard)
|
|
642
|
+
|
|
643
|
+
async def _sender_loop(self, peer: _PeerConn) -> None:
|
|
644
|
+
"""Drain ``peer.send_queue`` and write frames. One per peer."""
|
|
645
|
+
try:
|
|
646
|
+
while not self._closed:
|
|
647
|
+
msg = await peer.send_queue.get()
|
|
648
|
+
try:
|
|
649
|
+
await _write_frame(peer.writer, msg)
|
|
650
|
+
except (ConnectionError, asyncio.CancelledError):
|
|
651
|
+
return
|
|
652
|
+
except Exception:
|
|
653
|
+
# A single failed write must not kill the loop —
|
|
654
|
+
# log and continue with the next frame.
|
|
655
|
+
continue
|
|
656
|
+
except asyncio.CancelledError:
|
|
657
|
+
return
|
|
658
|
+
|
|
659
|
+
def _enqueue(self, peer: _PeerConn, frame: dict) -> None:
|
|
660
|
+
"""Enqueue a frame for ``peer``. Drops newest on overflow.
|
|
661
|
+
|
|
662
|
+
On drop: increments per-peer ``peer_drops_total``, scores down,
|
|
663
|
+
and graylists the peer if 3+ drops fall inside a 30s window
|
|
664
|
+
(matches the WS-G policy).
|
|
665
|
+
"""
|
|
666
|
+
try:
|
|
667
|
+
peer.send_queue.put_nowait(frame)
|
|
668
|
+
except asyncio.QueueFull:
|
|
669
|
+
self._peer_drops_total[peer.kid] = self._peer_drops_total.get(peer.kid, 0) + 1
|
|
670
|
+
peer.backpressure_drops += 1
|
|
671
|
+
now = time.time()
|
|
672
|
+
peer.drop_history.append(now)
|
|
673
|
+
cutoff = now - DROP_BURST_WINDOW_S
|
|
674
|
+
while peer.drop_history and peer.drop_history[0] < cutoff:
|
|
675
|
+
peer.drop_history.popleft()
|
|
676
|
+
if len(peer.drop_history) >= DROP_BURST_THRESHOLD:
|
|
677
|
+
self._graylist[peer.kid] = now + GRAYLIST_DURATION_S
|
|
678
|
+
# Schedule eviction; writer close happens here, full
|
|
679
|
+
# cleanup runs in _read_loop's finally.
|
|
680
|
+
try:
|
|
681
|
+
peer.writer.close()
|
|
682
|
+
except Exception:
|
|
683
|
+
pass
|
|
684
|
+
|
|
685
|
+
async def _send(self, peer: _PeerConn, msg: dict) -> None:
|
|
686
|
+
"""Back-compat direct-write shim.
|
|
687
|
+
|
|
688
|
+
Most code paths now go through ``_enqueue`` + the per-peer
|
|
689
|
+
sender task; this entrypoint stays for tests + callers that
|
|
690
|
+
need synchronous-ish "best effort" delivery semantics.
|
|
691
|
+
"""
|
|
692
|
+
try:
|
|
693
|
+
await _write_frame(peer.writer, msg)
|
|
694
|
+
except (ConnectionError, asyncio.CancelledError):
|
|
695
|
+
pass
|
|
696
|
+
|
|
697
|
+
# ---- scoring + graylist -------------------------------------------
|
|
698
|
+
|
|
699
|
+
def _is_graylisted(self, kid: bytes) -> bool:
|
|
700
|
+
expiry = self._graylist.get(kid)
|
|
701
|
+
if expiry is None:
|
|
702
|
+
return False
|
|
703
|
+
if time.time() >= expiry:
|
|
704
|
+
self._graylist.pop(kid, None)
|
|
705
|
+
return False
|
|
706
|
+
return True
|
|
707
|
+
|
|
708
|
+
def _maybe_graylist(self, peer: _PeerConn) -> None:
|
|
709
|
+
"""If ``peer.score()`` has dropped below threshold, graylist."""
|
|
710
|
+
if peer.score() < SCORE_GRAYLIST_THRESHOLD:
|
|
711
|
+
self._graylist[peer.kid] = time.time() + GRAYLIST_DURATION_S
|
|
712
|
+
try:
|
|
713
|
+
peer.writer.close()
|
|
714
|
+
except Exception:
|
|
715
|
+
pass
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
# ---- frame I/O ----------------------------------------------------------
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
async def _read_frame(reader: asyncio.StreamReader) -> dict:
|
|
722
|
+
header = await reader.readexactly(FRAME_HEADER.size)
|
|
723
|
+
(length,) = FRAME_HEADER.unpack(header)
|
|
724
|
+
if length > MAX_FRAME:
|
|
725
|
+
raise RuntimeError(f"frame too large: {length} bytes")
|
|
726
|
+
body = await reader.readexactly(length)
|
|
727
|
+
return cbor2.loads(body)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
async def _write_frame(writer: asyncio.StreamWriter, msg: dict) -> None:
|
|
731
|
+
body = cbor2.dumps(msg, canonical=True)
|
|
732
|
+
if len(body) > MAX_FRAME:
|
|
733
|
+
raise RuntimeError(f"frame too large: {len(body)} bytes")
|
|
734
|
+
writer.write(FRAME_HEADER.pack(len(body)))
|
|
735
|
+
writer.write(body)
|
|
736
|
+
await writer.drain()
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
async def _safe_invoke(handler: Handler, message: bytes) -> None:
|
|
740
|
+
try:
|
|
741
|
+
await handler(message)
|
|
742
|
+
except Exception as e:
|
|
743
|
+
try:
|
|
744
|
+
from hypermind.observability.recorder import get_recorder
|
|
745
|
+
|
|
746
|
+
get_recorder().incr(
|
|
747
|
+
"hypermind_handler_errors_total",
|
|
748
|
+
kind=type(e).__name__,
|
|
749
|
+
)
|
|
750
|
+
except Exception: # pragma: no cover
|
|
751
|
+
pass
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _now_ms() -> int:
|
|
755
|
+
return int(time.time() * 1000)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _verify_tls_alpn(writer: asyncio.StreamWriter) -> None:
|
|
759
|
+
"""Enforce §22.3: the negotiated ALPN protocol MUST be `hm/2`.
|
|
760
|
+
|
|
761
|
+
CPython's `ssl` module does not expose a selection callback, so we
|
|
762
|
+
enforce the §22.3 ban on ALPN-absent operation at the application
|
|
763
|
+
layer: if the peer offered no overlap, `selected_alpn_protocol()`
|
|
764
|
+
is None and we close the connection (`CASCADE-L2-ALPN`).
|
|
765
|
+
"""
|
|
766
|
+
from hypermind.transport.tls_profile import ALPN_HM2
|
|
767
|
+
|
|
768
|
+
ssl_obj = writer.get_extra_info("ssl_object")
|
|
769
|
+
if ssl_obj is None:
|
|
770
|
+
raise RuntimeError("TLS required but no SSL object on the connection")
|
|
771
|
+
selected = ssl_obj.selected_alpn_protocol()
|
|
772
|
+
if selected != ALPN_HM2:
|
|
773
|
+
raise RuntimeError(f"CASCADE-L2-ALPN: expected ALPN {ALPN_HM2!r}, got {selected!r}")
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _verify_tls_kid_binding(writer: asyncio.StreamWriter, claimed_kid: bytes) -> None:
|
|
777
|
+
"""Ensure the TLS peer cert's `id-hm-kid` SAN equals the application-
|
|
778
|
+
layer kid claimed during the CBOR handshake. Closes connection on
|
|
779
|
+
mismatch — implements `CASCADE-L2-KID-MISMATCH` (§22.5/§22.8).
|
|
780
|
+
"""
|
|
781
|
+
import ssl as _ssl
|
|
782
|
+
|
|
783
|
+
from hypermind.transport.tls_profile import extract_kid_from_cert
|
|
784
|
+
|
|
785
|
+
ssl_obj = writer.get_extra_info("ssl_object")
|
|
786
|
+
if ssl_obj is None:
|
|
787
|
+
raise RuntimeError("TLS required but no SSL object on the connection")
|
|
788
|
+
der = ssl_obj.getpeercert(binary_form=True)
|
|
789
|
+
if not der:
|
|
790
|
+
raise RuntimeError("TLS required but peer presented no certificate")
|
|
791
|
+
pem = _ssl.DER_cert_to_PEM_cert(der).encode("ascii")
|
|
792
|
+
cert_kid = extract_kid_from_cert(pem)
|
|
793
|
+
if cert_kid != claimed_kid:
|
|
794
|
+
raise RuntimeError(
|
|
795
|
+
f"CASCADE-L2-KID-MISMATCH: TLS cert kid {cert_kid.hex()[:16]}… "
|
|
796
|
+
f"!= handshake kid {claimed_kid.hex()[:16]}…"
|
|
797
|
+
)
|