elo-node 0.4.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.
elo/security.py ADDED
@@ -0,0 +1,201 @@
1
+ """Módulo de segurança do Elo — NKEYS, assinatura.
2
+
3
+ Camadas:
4
+ 1. Geração e armazenamento de identidade (ed25519)
5
+ 2. Assinatura de mensagens (prova de autoria)
6
+ 3. Verificação de assinaturas
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from cryptography.hazmat.primitives import serialization
18
+ from cryptography.hazmat.primitives.asymmetric import ed25519
19
+
20
+ # ── Caminho padrão da identidade ──────────────────────────
21
+
22
+ DEFAULT_KEY_DIR = Path.home() / ".elo"
23
+
24
+
25
+ # ── Geração de identidade ──────────────────────────────────
26
+
27
+
28
+ def generate_identity() -> tuple[ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey]:
29
+ """Gera um par de chaves ed25519 para identidade do nó.
30
+
31
+ Returns:
32
+ (private_key, public_key)
33
+ """
34
+ private_key = ed25519.Ed25519PrivateKey.generate()
35
+ public_key = private_key.public_key()
36
+ return private_key, public_key
37
+
38
+
39
+ # ── Codificação/decodificação ──────────────────────────────
40
+
41
+
42
+ def pubkey_to_id(public_key: ed25519.Ed25519PublicKey) -> str:
43
+ """Converte chave pública ed25519 em node_id (base64 URL-safe sem padding)."""
44
+ raw = public_key.public_bytes(
45
+ encoding=serialization.Encoding.Raw,
46
+ format=serialization.PublicFormat.Raw,
47
+ )
48
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
49
+
50
+
51
+ def id_to_pubkey(node_id: str) -> ed25519.Ed25519PublicKey:
52
+ """Converte node_id de volta para chave pública ed25519."""
53
+ raw = base64.urlsafe_b64decode(node_id + "==")
54
+ return ed25519.Ed25519PublicKey.from_public_bytes(raw)
55
+
56
+
57
+ # ── Armazenamento de identidade ────────────────────────────
58
+
59
+
60
+ def _chmod_0600(path: Path) -> None:
61
+ """Define permissão 0o600 no arquivo (Unix). No-op no Windows."""
62
+ try:
63
+ path.chmod(0o600)
64
+ except NotImplementedError:
65
+ pass
66
+
67
+
68
+ def save_identity(
69
+ private_key: ed25519.Ed25519PrivateKey,
70
+ key_dir: Path = DEFAULT_KEY_DIR,
71
+ ) -> Path:
72
+ """Salva a identidade do nó em disco.
73
+
74
+ Args:
75
+ private_key: Chave privada ed25519.
76
+ key_dir: Diretório para salvar os arquivos.
77
+
78
+ Returns:
79
+ Caminho do arquivo de seed salvo.
80
+ """
81
+ key_dir.mkdir(parents=True, exist_ok=True)
82
+
83
+ # Salva seed ed25519 (PKCS#8 PEM)
84
+ seed_path = key_dir / "identity.seed"
85
+ seed = private_key.private_bytes(
86
+ encoding=serialization.Encoding.PEM,
87
+ format=serialization.PrivateFormat.PKCS8,
88
+ encryption_algorithm=serialization.NoEncryption(),
89
+ )
90
+ seed_path.write_bytes(seed)
91
+ _chmod_0600(seed_path)
92
+
93
+ return seed_path
94
+
95
+
96
+ def load_identity(
97
+ key_dir: Path = DEFAULT_KEY_DIR,
98
+ ) -> tuple[ed25519.Ed25519PrivateKey, None]:
99
+ """Carrega a identidade do nó do disco.
100
+
101
+ Returns:
102
+ (private_key, None) — None mantido para compatibilidade de interface.
103
+ """
104
+ seed_path = key_dir / "identity.seed"
105
+ if not seed_path.exists():
106
+ raise FileNotFoundError(
107
+ f"Identidade não encontrada em {seed_path}. "
108
+ "Use elo.security.generate_and_save_identity() primeiro."
109
+ )
110
+
111
+ private_key: ed25519.Ed25519PrivateKey = serialization.load_pem_private_key(
112
+ seed_path.read_bytes(),
113
+ password=None,
114
+ ) # type: ignore[assignment]
115
+
116
+ return private_key, None
117
+
118
+
119
+ def generate_and_save_identity(
120
+ key_dir: Path = DEFAULT_KEY_DIR,
121
+ ) -> tuple[ed25519.Ed25519PublicKey, ed25519.Ed25519PrivateKey]:
122
+ """Gera e salva identidade (ed25519).
123
+
124
+ Returns:
125
+ (public_key, private_key)
126
+ """
127
+ private_key, public_key = generate_identity()
128
+ save_identity(private_key, key_dir)
129
+ return public_key, private_key
130
+
131
+
132
+ # ── Assinatura de mensagens ────────────────────────────────
133
+
134
+
135
+ def sign_message(private_key: ed25519.Ed25519PrivateKey, payload: dict[str, Any]) -> str:
136
+ """Assina um payload com a chave privada ed25519.
137
+
138
+ O payload é canonicalizado como JSON compacto antes de assinar.
139
+
140
+ Args:
141
+ private_key: Chave privada do nó.
142
+ payload: Dicionário a ser assinado.
143
+
144
+ Returns:
145
+ Assinatura em base64 URL-safe.
146
+ """
147
+ canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
148
+ signature = private_key.sign(canonical)
149
+ return base64.urlsafe_b64encode(signature).rstrip(b"=").decode()
150
+
151
+
152
+ def verify_signature(
153
+ public_key: ed25519.Ed25519PublicKey,
154
+ payload: dict[str, Any],
155
+ signature_b64: str,
156
+ ) -> bool:
157
+ """Verifica a assinatura de um payload.
158
+
159
+ Args:
160
+ public_key: Chave pública do remetente.
161
+ payload: Dicionário original.
162
+ signature_b64: Assinatura em base64 URL-safe.
163
+
164
+ Returns:
165
+ True se a assinatura for válida.
166
+ """
167
+ canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
168
+ try:
169
+ sig = base64.urlsafe_b64decode(signature_b64 + "==")
170
+ public_key.verify(sig, canonical)
171
+ return True
172
+ except Exception:
173
+ return False
174
+
175
+
176
+ # ── Utilitário de identidade efêmera (sem disco) ───────────
177
+
178
+
179
+ class EphemeralIdentity:
180
+ """Identidade temporária — útil para testes ou nós sem persistência."""
181
+
182
+ def __init__(self) -> None:
183
+ self._private_key, self._public_key = generate_identity()
184
+
185
+ @property
186
+ def node_id(self) -> str:
187
+ return pubkey_to_id(self._public_key)
188
+
189
+ @property
190
+ def private_key(self) -> ed25519.Ed25519PrivateKey:
191
+ return self._private_key
192
+
193
+ @property
194
+ def public_key(self) -> ed25519.Ed25519PublicKey:
195
+ return self._public_key
196
+
197
+ def sign(self, payload: dict[str, Any]) -> str:
198
+ return sign_message(self._private_key, payload)
199
+
200
+ def verify(self, payload: dict[str, Any], signature: str) -> bool:
201
+ return verify_signature(self._public_key, payload, signature)
@@ -0,0 +1,34 @@
1
+ """Elo P2P transport layer — zero-infra mesh networking.
2
+
3
+ Modules:
4
+ - tcp: TCP connection manager (server + peer connections)
5
+ - protocol: Wire protocol — framed JSON over TCP
6
+ - routing: Interest-based routing table
7
+ - tracker: Local capability registry (public/private)
8
+ """
9
+
10
+ from elo.transport.tcp import TCPManager, PeerConnection
11
+ from elo.transport.protocol import (
12
+ encode_frame, read_frame, write_frame,
13
+ hello_msg, hello_ack_msg,
14
+ query_msg, query_resp_msg,
15
+ interest_update_msg,
16
+ task_msg, result_msg,
17
+ heartbeat_msg, bye_msg,
18
+ MessageType, FrameError,
19
+ )
20
+ from elo.transport.routing import InterestTable
21
+ from elo.transport.tracker import LocalTracker
22
+
23
+ __all__ = [
24
+ "TCPManager", "PeerConnection",
25
+ "encode_frame", "read_frame", "write_frame",
26
+ "hello_msg", "hello_ack_msg",
27
+ "query_msg", "query_resp_msg",
28
+ "interest_update_msg",
29
+ "task_msg", "result_msg",
30
+ "heartbeat_msg", "bye_msg",
31
+ "MessageType", "FrameError",
32
+ "InterestTable",
33
+ "LocalTracker",
34
+ ]
@@ -0,0 +1,166 @@
1
+ """Wire protocol — framed JSON messages over TCP.
2
+
3
+ Frame format: [4-byte big-endian length][JSON payload]
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+ import struct
11
+ import time
12
+ from typing import Any
13
+
14
+ # ── Frame constants ────────────────────────────────────────
15
+
16
+ FRAME_HEADER_SIZE = 4 # uint32 big-endian
17
+ MAX_PAYLOAD_SIZE = 1024 * 1024 # 1 MB
18
+
19
+
20
+ class FrameError(Exception):
21
+ """Invalid frame received."""
22
+
23
+
24
+ # ── Message types ──────────────────────────────────────────
25
+
26
+ class MessageType:
27
+ HELLO = "hello"
28
+ HELLO_ACK = "hello_ack"
29
+ QUERY = "query"
30
+ QUERY_RESP = "query_resp"
31
+ INTEREST_UPDATE = "interest_update"
32
+ TASK = "task"
33
+ RESULT = "result"
34
+ EVENT = "event"
35
+ HEARTBEAT = "heartbeat"
36
+ BYE = "bye"
37
+ # DHT messages (v0.3+)
38
+ DHT_PING = "dht_ping"
39
+ DHT_PONG = "dht_pong"
40
+ DHT_FIND_NODE = "dht_find_node"
41
+ DHT_FIND_NODE_RESP = "dht_find_node_resp"
42
+ DHT_FIND_VALUE = "dht_find_value"
43
+ DHT_FIND_VALUE_RESP = "dht_find_value_resp"
44
+ DHT_STORE = "dht_store"
45
+ DHT_STORE_RESP = "dht_store_resp"
46
+
47
+
48
+ # ── Framing ────────────────────────────────────────────────
49
+
50
+
51
+ def encode_frame(payload: dict[str, Any]) -> bytes:
52
+ """Encodes a JSON dict into a framed message."""
53
+ data = json.dumps(payload, ensure_ascii=False).encode()
54
+ if len(data) > MAX_PAYLOAD_SIZE:
55
+ raise FrameError(f"Payload too large: {len(data)} bytes (max {MAX_PAYLOAD_SIZE})")
56
+ header = struct.pack("!I", len(data))
57
+ return header + data
58
+
59
+
60
+ async def read_frame(reader: asyncio.StreamReader) -> dict[str, Any]:
61
+ """Reads one frame from a StreamReader. Returns the decoded JSON dict."""
62
+ header = await reader.readexactly(FRAME_HEADER_SIZE)
63
+ length = struct.unpack("!I", header)[0]
64
+ if length > MAX_PAYLOAD_SIZE:
65
+ raise FrameError(f"Invalid payload length: {length} (max {MAX_PAYLOAD_SIZE})")
66
+ data = await reader.readexactly(length)
67
+ return json.loads(data.decode())
68
+
69
+
70
+ async def write_frame(writer: asyncio.StreamWriter, payload: dict[str, Any]) -> None:
71
+ """Writes one framed JSON message to a StreamWriter."""
72
+ writer.write(encode_frame(payload))
73
+ await writer.drain()
74
+
75
+
76
+ # ── Message builders ───────────────────────────────────────
77
+
78
+
79
+ def hello_msg(node_id: str, caps: dict, interests: list[str],
80
+ tracker: str = "public", version: str = "0.2.0") -> dict:
81
+ return {
82
+ "type": MessageType.HELLO,
83
+ "node_id": node_id,
84
+ "caps": caps,
85
+ "interests": interests,
86
+ "tracker": tracker,
87
+ "version": version,
88
+ }
89
+
90
+
91
+ def hello_ack_msg(node_id: str, caps: dict, interests: list[str],
92
+ tracker: str = "public") -> dict:
93
+ return {
94
+ "type": MessageType.HELLO_ACK,
95
+ "node_id": node_id,
96
+ "caps": caps,
97
+ "interests": interests,
98
+ "tracker": tracker,
99
+ }
100
+
101
+
102
+ def query_msg(capability: str, query_id: str, ttl: int = 5) -> dict:
103
+ return {
104
+ "type": MessageType.QUERY,
105
+ "capability": capability,
106
+ "id": query_id,
107
+ "ttl": ttl,
108
+ }
109
+
110
+
111
+ def query_resp_msg(query_id: str, nodes: list[dict]) -> dict:
112
+ return {
113
+ "type": MessageType.QUERY_RESP,
114
+ "id": query_id,
115
+ "nodes": nodes,
116
+ }
117
+
118
+
119
+ def interest_update_msg(interests: list[str]) -> dict:
120
+ return {
121
+ "type": MessageType.INTEREST_UPDATE,
122
+ "interests": interests,
123
+ }
124
+
125
+
126
+ def task_msg(task_id: str, target: str, caller: str, capability: str,
127
+ payload: dict, signature: str = "") -> dict:
128
+ return {
129
+ "type": MessageType.TASK,
130
+ "id": task_id,
131
+ "target": target,
132
+ "caller": caller,
133
+ "capability": capability,
134
+ "payload": payload,
135
+ "signature": signature,
136
+ "protocol": "elo.v1",
137
+ "timestamp": int(time.time()),
138
+ "ttl_s": 60,
139
+ }
140
+
141
+
142
+ def result_msg(task_id: str, status: str, payload: dict | None = None,
143
+ error: dict | None = None) -> dict:
144
+ msg = {
145
+ "type": MessageType.RESULT,
146
+ "id": task_id,
147
+ "status": status,
148
+ "protocol": "elo.v1",
149
+ }
150
+ if payload is not None:
151
+ msg["payload"] = payload
152
+ if error is not None:
153
+ msg["error"] = error
154
+ return msg
155
+
156
+
157
+ def heartbeat_msg(node_id: str) -> dict:
158
+ return {
159
+ "type": MessageType.HEARTBEAT,
160
+ "node_id": node_id,
161
+ "ts": int(time.time()),
162
+ }
163
+
164
+
165
+ def bye_msg() -> dict:
166
+ return {"type": MessageType.BYE}
@@ -0,0 +1,135 @@
1
+ """Interest-based routing table.
2
+
3
+ Each node maintains:
4
+ - Local caps: what this node provides
5
+ - Local interests: what this node is looking for
6
+ - Remote caps: per-peer, what capabilities each peer has
7
+ - Remote interests: per-peer, what each peer is looking for
8
+ - Forwarding table: capability → set of peer addresses that can serve it
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections import defaultdict
14
+ from typing import Any
15
+
16
+
17
+ class InterestTable:
18
+ """Interest-based routing for P2P capability discovery."""
19
+
20
+ def __init__(self) -> None:
21
+ self._local_caps: set[str] = set()
22
+ self._local_interests: set[str] = set()
23
+
24
+ # peer_address → {capability, ...}
25
+ self._peer_caps: dict[str, set[str]] = {}
26
+ self._peer_interests: dict[str, set[str]] = {}
27
+
28
+ # capability → {peer_address, ...}
29
+ self._forwarding: dict[str, set[str]] = defaultdict(set)
30
+
31
+ # ── local ───────────────────────────────────────────────
32
+
33
+ def set_local_caps(self, caps: dict[str, list[dict[str, Any]]]) -> None:
34
+ """Register local capabilities from agents, models, and tools."""
35
+ self._local_caps.clear()
36
+ for agent in caps.get("agents", []):
37
+ self._local_caps.add(agent.get("name", ""))
38
+ for model in caps.get("models", []):
39
+ self._local_caps.add(model.get("name", ""))
40
+ for tool in caps.get("tools", []):
41
+ self._local_caps.add(tool.get("name", ""))
42
+
43
+ def set_local_interests(self, interests: list[str]) -> None:
44
+ self._local_interests = set(interests)
45
+
46
+ def has_local(self, capability: str) -> bool:
47
+ return capability in self._local_caps
48
+
49
+ @property
50
+ def local_caps(self) -> set[str]:
51
+ return self._local_caps
52
+
53
+ @property
54
+ def local_interests(self) -> set[str]:
55
+ return self._local_interests
56
+
57
+ # ── peer ────────────────────────────────────────────────
58
+
59
+ def register_peer(self, peer_addr: str, caps: dict[str, list[dict[str, Any]]],
60
+ interests: list[str]) -> None:
61
+ """Register or update a peer's capabilities and interests."""
62
+ cap_set: set[str] = set()
63
+ for agent in caps.get("agents", []):
64
+ cap_set.add(agent.get("name", ""))
65
+ for model in caps.get("models", []):
66
+ cap_set.add(model.get("name", ""))
67
+ for tool in caps.get("tools", []):
68
+ cap_set.add(tool.get("name", ""))
69
+
70
+ self._peer_caps[peer_addr] = cap_set
71
+ self._peer_interests[peer_addr] = set(interests)
72
+
73
+ # Update forwarding table
74
+ for cap in cap_set:
75
+ self._forwarding[cap].add(peer_addr)
76
+
77
+ def remove_peer(self, peer_addr: str) -> None:
78
+ """Remove a peer and clean forwarding entries."""
79
+ old_caps = self._peer_caps.pop(peer_addr, set())
80
+ for cap in old_caps:
81
+ self._forwarding.get(cap, set()).discard(peer_addr)
82
+ self._peer_interests.pop(peer_addr, None)
83
+
84
+ def find_peer_for(self, capability: str) -> str | None:
85
+ """Find a peer that can serve the given capability."""
86
+ peers = self._forwarding.get(capability, set())
87
+ if peers:
88
+ return next(iter(peers))
89
+ return None
90
+
91
+ def find_all_peers_for(self, capability: str) -> set[str]:
92
+ """Return all known peers for a capability."""
93
+ return self._forwarding.get(capability, set()).copy()
94
+
95
+ def query_peers(self, capability: str, exclude: set[str] | None = None) -> list[str]:
96
+ """Find peers that might know about a capability.
97
+
98
+ First: direct matches from forwarding table.
99
+ Second: peers whose interests overlap (they're looking for similar things).
100
+ Third: random peers as a fallback.
101
+ """
102
+ exclude = exclude or set()
103
+ results: list[str] = []
104
+
105
+ # Direct matches
106
+ for peer in self._forwarding.get(capability, set()):
107
+ if peer not in exclude:
108
+ results.append(peer)
109
+
110
+ # Peers with overlapping interests (might know about this capability)
111
+ if not results:
112
+ for peer_addr, interests in self._peer_interests.items():
113
+ if peer_addr in exclude:
114
+ continue
115
+ # If the peer is interested in something, it might know
116
+ # about similar capabilities
117
+ if any(capability.startswith(i.split(":")[0])
118
+ for i in interests):
119
+ results.append(peer_addr)
120
+
121
+ # Fallback: all known peers
122
+ if not results:
123
+ results = [p for p in self._peer_caps if p not in exclude]
124
+
125
+ return results
126
+
127
+ @property
128
+ def known_peers(self) -> list[str]:
129
+ return list(self._peer_caps.keys())
130
+
131
+ def get_peer_caps(self, peer_addr: str) -> dict[str, set[str]]:
132
+ return {
133
+ "caps": self._peer_caps.get(peer_addr, set()),
134
+ "interests": self._peer_interests.get(peer_addr, set()),
135
+ }