neuronum 9.0.0__py3-none-any.whl → 10.0.1__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.

Potentially problematic release.


This version of neuronum might be problematic. Click here for more details.

neuronum/neuronum.py CHANGED
@@ -1,364 +1,745 @@
1
1
  import aiohttp
2
- from typing import Optional, AsyncGenerator, Union
2
+ import aiofiles
3
+ from typing import AsyncGenerator, Optional, Dict, Any, List
3
4
  import websockets
4
5
  import json
5
6
  import asyncio
6
7
  import base64
7
- import os
8
8
  import ssl
9
+ import os
10
+ import time
11
+ import logging
9
12
  from pathlib import Path
10
- from websockets.exceptions import ConnectionClosed
13
+ from dataclasses import dataclass
14
+ from websockets.exceptions import ConnectionClosed, WebSocketException
11
15
  from cryptography.hazmat.primitives.asymmetric import ec
12
16
  from cryptography.hazmat.primitives import serialization, hashes
13
17
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM
14
18
  from cryptography.hazmat.primitives.kdf.hkdf import HKDF
15
19
  from cryptography.hazmat.backends import default_backend
20
+ from abc import ABC, abstractmethod
16
21
 
17
- class Node:
18
- def __init__(self, id: str, private_key: str, public_key: str):
19
- self.node_id = id
20
- self.private_key_path = private_key
21
- self.public_key_path = public_key
22
- self.queue = asyncio.Queue()
23
- self.env = self._load_env()
24
- self.host = self.env.get("HOST", "")
25
- self.network = self.env.get("NETWORK", "")
26
- self.synapse = self.env.get("SYNAPSE", "")
27
- self.password = self.env.get("PASSWORD", "")
28
- self._private_key = self._load_private_key()
29
- self._public_key = self._load_public_key()
22
+ # Configure logging
23
+ logging.basicConfig(
24
+ level=logging.INFO,
25
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
26
+ )
27
+ logger = logging.getLogger(__name__)
30
28
 
31
29
 
32
- def to_dict(self) -> dict:
33
- return {
34
- "host": self.host,
35
- "password": self.password,
36
- "synapse": self.synapse
37
- }
38
-
30
+ # Custom Exceptions
31
+ class NeuronumError(Exception):
32
+ """Base exception for Neuronum errors"""
33
+ pass
39
34
 
40
- def _load_env(self) -> dict:
41
- credentials_folder_path = Path.home() / ".neuronum"
42
- env_path = credentials_folder_path / ".env"
43
- env_data = {}
44
- try:
45
- with open(env_path, "r") as f:
46
- for line in f:
47
- key, value = line.strip().split("=")
48
- env_data[key] = value
49
- return env_data
50
- except FileNotFoundError:
51
- print(f"Cell credentials (.env) not found at {env_path}")
52
- return {}
53
35
 
36
+ class AuthenticationError(NeuronumError):
37
+ """Raised when authentication fails"""
38
+ pass
54
39
 
55
- def _load_private_key(self):
56
- try:
57
- with open(self.private_key_path, "rb") as f:
58
- return serialization.load_pem_private_key(f.read(), password=None, backend=default_backend())
59
- except FileNotFoundError:
60
- print(f"Private key file not found at {self.private_key_path}.")
61
- return None
62
40
 
41
+ class EncryptionError(NeuronumError):
42
+ """Raised when encryption/decryption fails"""
43
+ pass
63
44
 
64
- def _load_public_key(self):
65
- try:
66
- with open(self.public_key_path, "rb") as f:
67
- return serialization.load_pem_public_key(f.read(), backend=default_backend())
68
- except FileNotFoundError:
69
- print(f"Public key file not found. Deriving from private key.")
70
- return self._private_key.public_key() if self._private_key else None
71
45
 
46
+ class CellNotFoundError(NeuronumError):
47
+ """Raised when a cell cannot be found"""
48
+ pass
49
+
50
+
51
+ class NetworkError(NeuronumError):
52
+ """Raised when network operations fail"""
53
+ pass
72
54
 
73
- def get_public_key_jwk(self):
74
- public_key = self._load_public_key()
75
- if not public_key:
76
- print("Public key not loaded. Cannot generate JWK.")
77
- return None
78
- public_numbers = public_key.public_numbers()
79
- x_bytes = public_numbers.x.to_bytes((public_numbers.x.bit_length() + 7) // 8, 'big')
80
- y_bytes = public_numbers.y.to_bytes((public_numbers.y.bit_length() + 7) // 8, 'big')
81
- return {
82
- "kty": "EC",
83
- "crv": "P-256",
84
- "x": base64.urlsafe_b64encode(x_bytes).rstrip(b'=').decode('utf-8'),
85
- "y": base64.urlsafe_b64encode(y_bytes).rstrip(b'=').decode('utf-8')
86
- }
87
55
 
56
+ # Configuration
57
+ @dataclass
58
+ class ClientConfig:
59
+ """Client configuration settings"""
60
+ network: str = "neuronum.net"
61
+ cache_expiry: int = 3600
62
+ credentials_path: Path = Path.home() / ".neuronum"
63
+ timeout: int = 30
64
+ max_retries: int = 3
65
+ retry_delay: float = 1.0
66
+ max_retry_delay: float = 60.0
88
67
 
89
- def _decrypt_with_ecdh_aesgcm(self, ephemeral_public_key_bytes, nonce, ciphertext):
68
+
69
+ class CryptoManager:
70
+ """Handles all cryptographic operations"""
71
+
72
+ def __init__(self, private_key: Optional[ec.EllipticCurvePrivateKey] = None):
73
+ self._private_key = private_key
74
+ self._public_key = private_key.public_key() if private_key else None
75
+
76
+ def sign_message(self, message: bytes) -> str:
77
+ """Sign a message with the private key"""
78
+ if not self._private_key:
79
+ raise EncryptionError("Private key not available for signing")
80
+
81
+ try:
82
+ signature = self._private_key.sign(message, ec.ECDSA(hashes.SHA256()))
83
+ return base64.b64encode(signature).decode()
84
+ except Exception as e:
85
+ logger.error("Failed to sign message", exc_info=True)
86
+ raise EncryptionError(f"Message signing failed: {e}")
87
+
88
+ def get_public_key_pem(self) -> str:
89
+ """Get public key in PEM format"""
90
+ if not self._public_key:
91
+ raise EncryptionError("Public key not available")
92
+
93
+ pem_bytes = self._public_key.public_bytes(
94
+ encoding=serialization.Encoding.PEM,
95
+ format=serialization.PublicFormat.SubjectPublicKeyInfo
96
+ )
97
+ return pem_bytes.decode('utf-8')
98
+
99
+ def load_public_key_from_pem(self, pem_string: str) -> ec.EllipticCurvePublicKey:
100
+ """Load a public key from PEM format"""
101
+ try:
102
+ return serialization.load_pem_public_key(
103
+ pem_string.encode(),
104
+ backend=default_backend()
105
+ )
106
+ except Exception as e:
107
+ logger.error("Failed to load public key from PEM", exc_info=True)
108
+ raise EncryptionError(f"Failed to load public key: {e}")
109
+
110
+ @staticmethod
111
+ def safe_b64decode(data: str) -> bytes:
112
+ """Safely decode base64 with proper padding"""
113
+ padding = 4 - (len(data) % 4)
114
+ if padding != 4:
115
+ data += '=' * padding
116
+ return base64.urlsafe_b64decode(data)
117
+
118
+ def encrypt_with_ecdh_aesgcm(
119
+ self,
120
+ public_key: ec.EllipticCurvePublicKey,
121
+ plaintext_dict: Dict[str, Any]
122
+ ) -> Dict[str, str]:
123
+ """Encrypt data using ECDH + AES-GCM"""
124
+ try:
125
+ ephemeral_private = ec.generate_private_key(ec.SECP256R1())
126
+ shared_secret = ephemeral_private.exchange(ec.ECDH(), public_key)
127
+ derived_key = HKDF(
128
+ algorithm=hashes.SHA256(),
129
+ length=32,
130
+ salt=None,
131
+ info=b'handshake data'
132
+ ).derive(shared_secret)
133
+
134
+ aesgcm = AESGCM(derived_key)
135
+ nonce = os.urandom(12)
136
+ plaintext_bytes = json.dumps(plaintext_dict).encode()
137
+ ciphertext = aesgcm.encrypt(nonce, plaintext_bytes, None)
138
+
139
+ ephemeral_public_bytes = ephemeral_private.public_key().public_bytes(
140
+ serialization.Encoding.X962,
141
+ serialization.PublicFormat.UncompressedPoint
142
+ )
143
+
144
+ return {
145
+ 'ciphertext': base64.urlsafe_b64encode(ciphertext).rstrip(b'=').decode(),
146
+ 'nonce': base64.urlsafe_b64encode(nonce).rstrip(b'=').decode(),
147
+ 'ephemeralPublicKey': base64.urlsafe_b64encode(ephemeral_public_bytes).rstrip(b'=').decode()
148
+ }
149
+ except Exception as e:
150
+ logger.error("Encryption failed", exc_info=True)
151
+ raise EncryptionError(f"Encryption failed: {e}")
152
+
153
+ def decrypt_with_ecdh_aesgcm(
154
+ self,
155
+ ephemeral_public_key_bytes: bytes,
156
+ nonce: bytes,
157
+ ciphertext: bytes
158
+ ) -> Dict[str, Any]:
159
+ """Decrypt data using ECDH + AES-GCM"""
160
+ if not self._private_key:
161
+ raise EncryptionError("Private key not available for decryption")
162
+
90
163
  try:
91
164
  ephemeral_public_key = ec.EllipticCurvePublicKey.from_encoded_point(
92
165
  ec.SECP256R1(), ephemeral_public_key_bytes
93
166
  )
94
167
  shared_secret = self._private_key.exchange(ec.ECDH(), ephemeral_public_key)
95
- derived_key = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=b'handshake data').derive(shared_secret)
168
+ derived_key = HKDF(
169
+ algorithm=hashes.SHA256(),
170
+ length=32,
171
+ salt=None,
172
+ info=b'handshake data'
173
+ ).derive(shared_secret)
174
+
96
175
  aesgcm = AESGCM(derived_key)
97
176
  plaintext_bytes = aesgcm.decrypt(nonce, ciphertext, None)
98
177
  return json.loads(plaintext_bytes.decode())
99
178
  except Exception as e:
100
- print(f"Decryption failed: {e}")
101
- return None
102
-
103
-
104
- def _encrypt_with_ecdh_aesgcm(self, public_key, plaintext_dict):
105
- ephemeral_private = ec.generate_private_key(ec.SECP256R1())
106
- shared_secret = ephemeral_private.exchange(ec.ECDH(), public_key)
107
- derived_key = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=b'handshake data').derive(shared_secret)
108
- aesgcm = AESGCM(derived_key)
109
- nonce = os.urandom(12)
110
- plaintext_bytes = json.dumps(plaintext_dict).encode()
111
- ciphertext = aesgcm.encrypt(nonce, plaintext_bytes, None)
112
- ephemeral_public_bytes = ephemeral_private.public_key().public_bytes(
113
- serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
114
- )
115
- return {
116
- 'ciphertext': base64.urlsafe_b64encode(ciphertext).rstrip(b'=').decode(),
117
- 'nonce': base64.urlsafe_b64encode(nonce).rstrip(b'=').decode(),
118
- 'ephemeralPublicKey': base64.urlsafe_b64encode(ephemeral_public_bytes).rstrip(b'=').decode()
119
- }
179
+ logger.error("Decryption failed", exc_info=True)
180
+ raise EncryptionError(f"Decryption failed: {e}")
120
181
 
121
182
 
122
- def _load_public_key_from_jwk(self, jwk):
183
+ class CacheManager:
184
+ """Manages cell cache with async file operations"""
185
+
186
+ def __init__(self, config: ClientConfig):
187
+ self.config = config
188
+ self.cache_file = config.credentials_path / "cells.json"
189
+ self._lock = asyncio.Lock()
190
+ self._memory_cache: Optional[List[Dict[str, Any]]] = None
191
+ self._cache_time: Optional[float] = None
192
+
193
+ async def get_cells(self) -> List[Dict[str, Any]]:
194
+ """Get cached cells if valid, otherwise fetch new"""
195
+ async with self._lock:
196
+ # Check memory cache first
197
+ if self._is_memory_cache_valid():
198
+ logger.debug("Using in-memory cache")
199
+ return self._memory_cache
200
+
201
+ # Check file cache
202
+ if await self._is_file_cache_valid():
203
+ logger.debug("Using file cache")
204
+ cells = await self._load_from_file()
205
+ self._update_memory_cache(cells)
206
+ return cells
207
+
208
+ return None
209
+
210
+ async def update_cells(self, cells: List[Dict[str, Any]]) -> None:
211
+ """Update cache with new cell data"""
212
+ async with self._lock:
213
+ self._update_memory_cache(cells)
214
+ await self._save_to_file(cells)
215
+
216
+ def _is_memory_cache_valid(self) -> bool:
217
+ """Check if memory cache is still valid"""
218
+ if not self._memory_cache or not self._cache_time:
219
+ return False
220
+ return (time.time() - self._cache_time) < self.config.cache_expiry
221
+
222
+ async def _is_file_cache_valid(self) -> bool:
223
+ """Check if file cache is still valid"""
224
+ if not self.cache_file.exists():
225
+ return False
226
+
123
227
  try:
124
- x_bytes = base64.urlsafe_b64decode(jwk['x'] + '==')
125
- y_bytes = base64.urlsafe_b64decode(jwk['y'] + '==')
126
- public_numbers = ec.EllipticCurvePublicNumbers(
127
- int.from_bytes(x_bytes, 'big'),
128
- int.from_bytes(y_bytes, 'big'),
129
- ec.SECP256R1()
130
- )
131
- return public_numbers.public_key(default_backend())
228
+ file_mtime = os.path.getmtime(self.cache_file)
229
+ return (time.time() - file_mtime) < self.config.cache_expiry
230
+ except OSError:
231
+ return False
232
+
233
+ async def _load_from_file(self) -> List[Dict[str, Any]]:
234
+ """Load cells from cache file"""
235
+ try:
236
+ async with aiofiles.open(self.cache_file, 'r') as f:
237
+ content = await f.read()
238
+ return json.loads(content)
239
+ except (FileNotFoundError, json.JSONDecodeError) as e:
240
+ logger.warning(f"Failed to load cache file: {e}")
241
+ return []
242
+
243
+ async def _save_to_file(self, cells: List[Dict[str, Any]]) -> None:
244
+ """Save cells to cache file"""
245
+ try:
246
+ self.config.credentials_path.mkdir(parents=True, exist_ok=True)
247
+ async with aiofiles.open(self.cache_file, 'w') as f:
248
+ await f.write(json.dumps(cells, indent=4))
249
+ logger.debug("Cache file updated")
132
250
  except Exception as e:
133
- print(f"Error loading public key from JWK: {e}")
134
- return None
251
+ logger.error(f"Failed to save cache file: {e}")
252
+
253
+ def _update_memory_cache(self, cells: List[Dict[str, Any]]) -> None:
254
+ """Update in-memory cache"""
255
+ self._memory_cache = cells
256
+ self._cache_time = time.time()
135
257
 
136
258
 
137
- def _load_public_key_from_pem(self, pem_string: str):
259
+ class NetworkClient:
260
+ """Handles all network operations with retry logic"""
261
+
262
+ def __init__(self, config: ClientConfig):
263
+ self.config = config
264
+ self._session: Optional[aiohttp.ClientSession] = None
265
+
266
+ async def __aenter__(self):
267
+ self._session = aiohttp.ClientSession(
268
+ timeout=aiohttp.ClientTimeout(total=self.config.timeout)
269
+ )
270
+ return self
271
+
272
+ async def __aexit__(self, *args):
273
+ if self._session:
274
+ await self._session.close()
275
+
276
+ async def post_request(
277
+ self,
278
+ url: str,
279
+ payload: Dict[str, Any],
280
+ retry_count: int = 0
281
+ ) -> Optional[Dict[str, Any]]:
282
+ """Make POST request with retry logic"""
283
+ if not self._session:
284
+ self._session = aiohttp.ClientSession(
285
+ timeout=aiohttp.ClientTimeout(total=self.config.timeout)
286
+ )
287
+
138
288
  try:
139
- corrected_pem = pem_string.replace("-----BEGINPUBLICKEY-----", "-----BEGIN PUBLIC KEY-----") \
140
- .replace("-----ENDPUBLICKEY-----", "-----END PUBLIC KEY-----")
141
- public_key = serialization.load_pem_public_key(corrected_pem.encode(), backend=default_backend())
142
- return public_key
289
+ async with self._session.post(url, json=payload) as response:
290
+ response.raise_for_status()
291
+ return await response.json()
292
+ except aiohttp.ClientResponseError as e:
293
+ logger.error(f"HTTP error {e.status} for URL: {url}")
294
+ if retry_count < self.config.max_retries and e.status >= 500:
295
+ return await self._retry_request(url, payload, retry_count)
296
+ raise NetworkError(f"HTTP {e.status} error")
297
+ except aiohttp.ClientError as e:
298
+ logger.error(f"Client error for URL {url}: {e}")
299
+ if retry_count < self.config.max_retries:
300
+ return await self._retry_request(url, payload, retry_count)
301
+ raise NetworkError(f"Client error: {e}")
143
302
  except Exception as e:
144
- print(f"Error loading public key from PEM: {e}")
145
- return None
146
-
147
-
148
- async def _get_target_node_public_key(self, node_id: str):
149
- nodes = await self.list_nodes()
150
- for node in nodes:
151
- app_metadata = node.get('config', {}).get('app_metadata', {})
152
- if app_metadata.get('node_id') == node_id:
153
- pem = node.get('config', {}).get('public_key')
154
- if not pem:
155
- print(f"Public key missing for node: {node_id}")
156
- return None
157
- public_key = self._load_public_key_from_pem(pem)
158
- if not public_key:
159
- return None
160
- return public_key
161
- print(f"Target node not found: {node_id}")
162
- return None
163
-
164
-
165
- async def _post_request(self, url, payload):
166
- async with aiohttp.ClientSession() as session:
167
- try:
168
- async with session.post(url, json=payload) as response:
169
- response.raise_for_status()
170
- return await response.json()
171
- except aiohttp.ClientError as e:
172
- print(f"HTTP Error: {e.status}, URL: {url}")
173
- except Exception as e:
174
- print(f"Unexpected error: {e}")
175
- return None
303
+ logger.error(f"Unexpected error for URL {url}: {e}")
304
+ raise NetworkError(f"Unexpected error: {e}")
305
+
306
+ async def _retry_request(
307
+ self,
308
+ url: str,
309
+ payload: Dict[str, Any],
310
+ retry_count: int
311
+ ) -> Optional[Dict[str, Any]]:
312
+ """Retry request with exponential backoff"""
313
+ delay = min(
314
+ self.config.retry_delay * (2 ** retry_count),
315
+ self.config.max_retry_delay
316
+ )
317
+ logger.info(f"Retrying request in {delay}s (attempt {retry_count + 1})")
318
+ await asyncio.sleep(delay)
319
+ return await self.post_request(url, payload, retry_count + 1)
176
320
 
177
321
 
178
- async def list_nodes(self):
179
- full_url = f"https://{self.network}/api/list_nodes"
322
+ class BaseClient(ABC):
323
+ """Base client with common functionality"""
324
+
325
+ def __init__(self, config: Optional[ClientConfig] = None):
326
+ self.config = config or ClientConfig()
327
+ self.env: Dict[str, str] = {}
328
+ self._crypto: Optional[CryptoManager] = None
329
+ self._cache_manager = CacheManager(self.config)
330
+ self._network_client = NetworkClient(self.config)
331
+ self.host = ""
332
+ self.network = self.config.network
333
+
334
+ @abstractmethod
335
+ def _load_private_key(self) -> Optional[ec.EllipticCurvePrivateKey]:
336
+ """Load private key - to be implemented by subclasses"""
337
+ pass
338
+
339
+ def _init_crypto(self, private_key: Optional[ec.EllipticCurvePrivateKey]) -> None:
340
+ """Initialize crypto manager with private key"""
341
+ self._crypto = CryptoManager(private_key)
342
+
343
+ def to_dict(self) -> Dict[str, str]:
344
+ """Create authentication payload"""
345
+ if not self._crypto:
346
+ logger.warning("Crypto manager not initialized")
347
+ timestamp = str(int(time.time()))
348
+ return {
349
+ "host": self.host,
350
+ "signed_message": "",
351
+ "message": f"host={self.host};timestamp={timestamp}"
352
+ }
353
+
354
+ timestamp = str(int(time.time()))
355
+ message = f"host={self.host};timestamp={timestamp}"
356
+
357
+ try:
358
+ signed_message = self._crypto.sign_message(message.encode())
359
+ except EncryptionError:
360
+ logger.error("Failed to sign authentication message")
361
+ signed_message = ""
362
+
363
+ return {
364
+ "host": self.host,
365
+ "signed_message": signed_message,
366
+ "message": message
367
+ }
368
+
369
+ async def _get_target_cell_public_key(self, cell_id: str) -> str:
370
+ """Get public key for target cell"""
371
+ # Try cached cells first
372
+ cells = await self.list_cells(update=False)
373
+
374
+ for cell in cells:
375
+ if cell.get('cell_id') == cell_id:
376
+ public_key = cell.get('public_key', {})
377
+ if public_key:
378
+ return public_key
379
+
380
+ # Refresh cache and try again
381
+ logger.info(f"Cell {cell_id} not in cache, refreshing")
382
+ cells = await self.list_cells(update=True)
383
+
384
+ for cell in cells:
385
+ if cell.get('cell_id') == cell_id:
386
+ public_key = cell.get('public_key', {})
387
+ if public_key:
388
+ return public_key
389
+
390
+ raise CellNotFoundError(f"Cell not found: {cell_id}")
391
+
392
+ async def list_cells(self, update: bool = False) -> List[Dict[str, Any]]:
393
+ """
394
+ List all available cells.
395
+
396
+ Args:
397
+ update: Force cache refresh if True
398
+
399
+ Returns:
400
+ List of cell information dictionaries
401
+ """
402
+ if not update:
403
+ cached_cells = await self._cache_manager.get_cells()
404
+ if cached_cells is not None:
405
+ return cached_cells
406
+
407
+ # Fetch from API
408
+ full_url = f"https://{self.network}/api/list_cells"
180
409
  payload = {"cell": self.to_dict()}
181
- data = await self._post_request(full_url, payload)
182
- return data.get("Nodes", []) if data else []
183
-
184
-
185
- async def tx_response(self, transmitter_id: str, data: dict, client_public_key_str: Optional[Union[str, dict]] = None, encrypted: Optional[bool] = True):
410
+
411
+ try:
412
+ data = await self._network_client.post_request(full_url, payload)
413
+ cells = data.get("Cells", []) if data else []
414
+ await self._cache_manager.update_cells(cells)
415
+ return cells
416
+ except NetworkError as e:
417
+ logger.error(f"Failed to fetch cells: {e}")
418
+ return []
419
+
420
+ async def tx_response(
421
+ self,
422
+ transmitter_id: str,
423
+ data: Dict[str, Any],
424
+ client_public_key_str: str
425
+ ) -> None:
426
+ """
427
+ Send an encrypted response to a transmitter.
428
+
429
+ Args:
430
+ transmitter_id: ID of the transmitter
431
+ data: Response data to encrypt
432
+ client_public_key_str: Client's public key in PEM format
433
+
434
+ Raises:
435
+ EncryptionError: If encryption fails
436
+ NetworkError: If network request fails
437
+ """
438
+ if not self._crypto:
439
+ raise EncryptionError("Crypto manager not initialized")
440
+
441
+ if not client_public_key_str:
442
+ raise ValueError("client_public_key_str is required")
443
+
186
444
  url = f"https://{self.network}/api/tx_response/{transmitter_id}"
187
445
 
188
- if encrypted:
189
- if not client_public_key_str:
190
- print("Error: client_public_key_str is required for encrypted responses.")
191
- return
192
-
193
- public_key_jwk = json.loads(client_public_key_str) if isinstance(client_public_key_str, str) else client_public_key_str
194
- public_key = self._load_public_key_from_jwk(public_key_jwk)
195
- if not public_key:
196
- return
197
-
198
- encrypted_payload = self._encrypt_with_ecdh_aesgcm(public_key, data)
199
- payload = {"data": encrypted_payload, "cell": self.to_dict()}
200
- else:
201
- payload = {"data": data, "cell": self.to_dict()}
446
+ public_key = self._crypto.load_public_key_from_pem(client_public_key_str)
447
+ encrypted_payload = self._crypto.encrypt_with_ecdh_aesgcm(public_key, data)
448
+ payload = {"data": encrypted_payload, "cell": self.to_dict()}
202
449
 
203
- await self._post_request(url, payload)
204
-
205
-
206
- async def activate_tx(self, node_id: str, data: dict, encrypted: Optional[bool] = True):
207
- url = f"https://{self.network}/api/activate_tx/{node_id}"
450
+ await self._network_client.post_request(url, payload)
451
+ logger.info(f"Response sent to transmitter {transmitter_id}")
452
+
453
+ async def activate_tx(
454
+ self,
455
+ cell_id: str,
456
+ data: Dict[str, Any]
457
+ ) -> Optional[Dict[str, Any]]:
458
+ """
459
+ Activate a transaction with the specified cell.
460
+
461
+ Args:
462
+ cell_id: Target cell identifier
463
+ data: Transaction data to encrypt and send
464
+
465
+ Returns:
466
+ Decrypted response dict or None on failure
467
+
468
+ Raises:
469
+ CellNotFoundError: If cell_id is not found
470
+ EncryptionError: If encryption/decryption fails
471
+ NetworkError: If network request fails
472
+ """
473
+ if not self._crypto:
474
+ raise EncryptionError("Crypto manager not initialized")
475
+
476
+ url = f"https://{self.network}/api/activate_tx/{cell_id}"
208
477
  payload = {"cell": self.to_dict()}
209
478
 
210
- if encrypted:
211
- public_key = await self._get_target_node_public_key(node_id)
212
- if not public_key: return None
213
- data_to_encrypt = data.copy()
214
- data_to_encrypt["publicKey"] = self.get_public_key_jwk()
215
- encrypted_payload = self._encrypt_with_ecdh_aesgcm(public_key, data_to_encrypt)
216
- payload["data"] = {"encrypted": encrypted_payload}
217
- else:
218
- payload["data"] = data
219
-
220
- response_data = await self._post_request(url, payload)
221
-
222
- if encrypted:
223
- if not response_data or "response" not in response_data:
224
- print("Unexpected or missing response.")
225
- return response_data
226
- inner_response = response_data["response"]
227
- if "ciphertext" in inner_response:
228
- ephemeral_public_key_bytes = base64.urlsafe_b64decode(inner_response["ephemeralPublicKey"] + '==')
229
- nonce = base64.urlsafe_b64decode(inner_response["nonce"] + '==')
230
- ciphertext = base64.urlsafe_b64decode(inner_response["ciphertext"] + '==')
231
- return self._decrypt_with_ecdh_aesgcm(ephemeral_public_key_bytes, nonce, ciphertext)
232
- else:
233
- print("Server response was not encrypted as expected.")
234
- return inner_response
235
- else:
479
+ # Get target cell's public key
480
+ public_key_pem_str = await self._get_target_cell_public_key(cell_id)
481
+ public_key_object = self._crypto.load_public_key_from_pem(public_key_pem_str)
482
+
483
+ # Prepare and encrypt data
484
+ data_to_encrypt = data.copy()
485
+ data_to_encrypt["public_key"] = self._crypto.get_public_key_pem()
486
+ encrypted_payload = self._crypto.encrypt_with_ecdh_aesgcm(
487
+ public_key_object,
488
+ data_to_encrypt
489
+ )
490
+ payload["data"] = {"encrypted": encrypted_payload}
491
+
492
+ response_data = await self._network_client.post_request(url, payload)
493
+
494
+ if not response_data or "response" not in response_data:
495
+ logger.warning("Unexpected or missing response")
236
496
  return response_data
237
-
238
-
239
- async def sync(self) -> AsyncGenerator[str, None]:
240
- full_url = f"wss://{self.network}/sync/{self.node_id}"
241
- auth_payload = {
242
- "host": self.host,
243
- "password": self.password,
244
- "synapse": self.synapse,
245
- }
497
+
498
+ inner_response = response_data["response"]
499
+
500
+ # Decrypt response if encrypted
501
+ if "ciphertext" in inner_response:
502
+ try:
503
+ ephemeral_public_key_bytes = CryptoManager.safe_b64decode(
504
+ inner_response["ephemeralPublicKey"]
505
+ )
506
+ nonce = CryptoManager.safe_b64decode(inner_response["nonce"])
507
+ ciphertext = CryptoManager.safe_b64decode(inner_response["ciphertext"])
508
+
509
+ return self._crypto.decrypt_with_ecdh_aesgcm(
510
+ ephemeral_public_key_bytes, nonce, ciphertext
511
+ )
512
+ except EncryptionError:
513
+ logger.error("Failed to decrypt response")
514
+ return None
515
+ else:
516
+ logger.debug("Received unencrypted response")
517
+ return inner_response
518
+
519
+ async def sync(self) -> AsyncGenerator[Dict[str, Any], None]:
520
+ """
521
+ Sync with the network and yield operations as they arrive.
522
+
523
+ Yields:
524
+ Operation dictionaries with decrypted data
525
+
526
+ Raises:
527
+ ValueError: If not called from Cell instance or host not set
528
+ """
529
+ if not isinstance(self, Cell):
530
+ raise ValueError("sync must be called from a Cell instance")
531
+
532
+ cell = getattr(self, 'host', None)
533
+ if not cell:
534
+ raise ValueError("host is required for Cell sync")
535
+
536
+ full_url = f"wss://{self.network}/sync/{cell}"
537
+ auth_payload = self.to_dict()
538
+
539
+ logger.info(f"Starting sync with {cell}")
540
+
541
+ retry_count = 0
246
542
  while True:
247
543
  try:
248
- async with websockets.connect(full_url) as ws:
544
+ ssl_context = ssl.create_default_context()
545
+
546
+ async with websockets.connect(full_url, ssl=ssl_context) as ws:
249
547
  await ws.send(json.dumps(auth_payload))
250
- print("Node syncing...")
548
+ logger.info(f"Connected and authenticated to {cell}")
549
+ retry_count = 0 # Reset on successful connection
550
+
251
551
  while True:
252
552
  try:
253
- raw_operation = await ws.recv()
553
+ raw_operation = await asyncio.wait_for(
554
+ ws.recv(),
555
+ timeout=self.config.timeout
556
+ )
254
557
  operation = json.loads(raw_operation)
255
558
 
256
559
  if "encrypted" in operation.get("data", {}):
257
560
  encrypted_data = operation["data"]["encrypted"]
258
561
 
259
- ephemeral_public_key_b64 = encrypted_data["ephemeralPublicKey"]
260
- ephemeral_public_key_b64 += '=' * ((4 - len(ephemeral_public_key_b64) % 4) % 4)
261
- ephemeral_public_key_bytes = base64.urlsafe_b64decode(ephemeral_public_key_b64)
262
-
263
- nonce_b64 = encrypted_data["nonce"]
264
- nonce_b64 += '=' * ((4 - len(nonce_b64) % 4) % 4)
265
- nonce = base64.urlsafe_b64decode(nonce_b64)
266
-
267
- ciphertext_b64 = encrypted_data["ciphertext"]
268
- ciphertext_b64 += '=' * ((4 - len(ciphertext_b64) % 4) % 4)
269
- ciphertext = base64.urlsafe_b64decode(ciphertext_b64)
270
-
271
- decrypted_data = self._decrypt_with_ecdh_aesgcm(
272
- ephemeral_public_key_bytes, nonce, ciphertext
273
- )
274
-
275
- if decrypted_data:
562
+ try:
563
+ ephemeral_public_key_bytes = CryptoManager.safe_b64decode(
564
+ encrypted_data["ephemeralPublicKey"]
565
+ )
566
+ nonce = CryptoManager.safe_b64decode(
567
+ encrypted_data["nonce"]
568
+ )
569
+ ciphertext = CryptoManager.safe_b64decode(
570
+ encrypted_data["ciphertext"]
571
+ )
572
+
573
+ decrypted_data = self._crypto.decrypt_with_ecdh_aesgcm(
574
+ ephemeral_public_key_bytes, nonce, ciphertext
575
+ )
576
+
276
577
  operation["data"].update(decrypted_data)
277
578
  operation["data"].pop("encrypted")
278
579
  yield operation
279
- else:
280
- print("Failed to decrypt incoming data. Skipping...")
580
+ except EncryptionError:
581
+ logger.error("Failed to decrypt operation")
281
582
  else:
282
- yield operation
583
+ logger.warning("Received unencrypted data")
584
+
283
585
  except asyncio.TimeoutError:
284
- print("No data received. Continuing...")
586
+ await ws.ping()
587
+ continue
285
588
  except ConnectionClosed as e:
286
- if e.code == 1000:
287
- print(f"WebSocket closed cleanly (code 1000). Reconnecting...")
288
- else:
289
- print(f"Connection closed with error code {e.code}: {e.reason}. Reconnecting...")
589
+ logger.warning(f"Connection closed: {e.code} - {e.reason}")
290
590
  break
291
591
  except Exception as e:
292
- print(f"Unexpected error in recv loop: {e}")
592
+ logger.error(f"Error in receive loop: {e}")
293
593
  break
294
- except websockets.exceptions.WebSocketException as e:
295
- print(f"WebSocket error occurred: {e}. Retrying in 5 seconds...")
594
+
595
+ except WebSocketException as e:
596
+ logger.error(f"WebSocket error: {e}")
296
597
  except Exception as e:
297
- print(f"General error occurred: {e}. Retrying in 5 seconds...")
298
- await asyncio.sleep(3)
299
-
300
-
301
- async def stream(self, label: str, data: dict, node_id: str = None, encrypted: Optional[bool] = True, retry_delay: int = 3):
302
- context = ssl.create_default_context()
303
- context.check_hostname = True
304
- context.verify_mode = ssl.CERT_REQUIRED
305
-
306
- target_node_public_key = None
307
- if encrypted:
308
- target_node_public_key = await self._get_target_node_public_key(node_id)
309
- if not target_node_public_key:
310
- print("Failed to get target node's public key. Cannot stream data.")
311
- return
312
-
313
- while True:
314
- try:
315
- reader, writer = await asyncio.open_connection(self.network, 55555, ssl=context, server_hostname=self.network)
316
-
317
- credentials = f"{self.host}\n{self.password}\n{self.synapse}\n{node_id}\n"
318
- writer.write(credentials.encode("utf-8"))
319
- await writer.drain()
320
-
321
- response = await reader.read(1024)
322
- response_text = response.decode("utf-8").strip()
323
-
324
- if "Authentication successful" not in response_text:
325
- print("Authentication failed, retrying...")
326
- writer.close()
327
- await writer.wait_closed()
328
- await asyncio.sleep(retry_delay)
329
- continue
598
+ logger.error(f"General error in sync: {e}")
599
+
600
+ # Exponential backoff for reconnection
601
+ retry_count += 1
602
+ delay = min(
603
+ self.config.retry_delay * (2 ** retry_count),
604
+ self.config.max_retry_delay
605
+ )
606
+ logger.info(f"Reconnecting in {delay}s")
607
+ await asyncio.sleep(delay)
608
+
609
+ async def stream(self, cell_id: str, data: Dict[str, Any]) -> bool:
610
+ """
611
+ Stream data to a target cell.
612
+
613
+ Args:
614
+ cell_id: Target cell identifier
615
+ data: Data to encrypt and stream
616
+
617
+ Returns:
618
+ True if successful, False otherwise
619
+
620
+ Raises:
621
+ ValueError: If not called from Cell instance or host not set
622
+ CellNotFoundError: If cell_id is not found
623
+ EncryptionError: If encryption fails
624
+ """
625
+ if not isinstance(self, Cell):
626
+ raise ValueError("stream must be called from a Cell instance")
627
+
628
+ if not getattr(self, 'host', None):
629
+ raise ValueError("host is required for Cell stream")
630
+
631
+ if not self._crypto:
632
+ raise EncryptionError("Crypto manager not initialized")
633
+
634
+ # Get target cell's public key
635
+ public_key_pem_str = await self._get_target_cell_public_key(cell_id)
636
+ public_key_object = self._crypto.load_public_key_from_pem(public_key_pem_str)
637
+
638
+ # Prepare and encrypt data
639
+ data_to_encrypt = data.copy()
640
+ data_to_encrypt["public_key"] = self._crypto.get_public_key_pem()
641
+ encrypted_payload = self._crypto.encrypt_with_ecdh_aesgcm(
642
+ public_key_object,
643
+ data_to_encrypt
644
+ )
645
+
646
+ auth_payload = self.to_dict()
647
+ data_payload = {"data": {"encrypted": encrypted_payload}}
648
+ send_payload = {**auth_payload, **data_payload}
649
+
650
+ full_url = f"wss://{self.network}/stream/{cell_id}"
651
+
652
+ try:
653
+ ssl_context = ssl.create_default_context()
654
+ async with websockets.connect(full_url, ssl=ssl_context) as ws:
655
+ await ws.send(json.dumps(send_payload))
656
+ logger.info(f"Data streamed to {cell_id}")
330
657
 
331
- stream_payload = {
332
- "label": label,
333
- "data": data.copy()
334
- }
658
+ try:
659
+ ack = await asyncio.wait_for(ws.recv(), timeout=2)
660
+ logger.debug(f"Server acknowledgment: {ack}")
661
+ except asyncio.TimeoutError:
662
+ logger.debug("No immediate acknowledgment (data sent)")
663
+ except Exception as e:
664
+ logger.warning(f"Error reading acknowledgment: {e}")
335
665
 
336
- if encrypted:
337
- data_to_encrypt = data.copy()
338
- data_to_encrypt["publicKey"] = self.get_public_key_jwk()
339
-
340
- encrypted_payload = self._encrypt_with_ecdh_aesgcm(target_node_public_key, data_to_encrypt)
341
- stream_payload["data"] = {"encrypted": encrypted_payload}
342
-
343
- writer.write(json.dumps(stream_payload).encode("utf-8"))
344
- await writer.drain()
345
-
346
- response = await reader.read(1024)
347
- response_text = response.decode("utf-8").strip()
666
+ return True
667
+ except WebSocketException as e:
668
+ logger.error(f"WebSocket error during stream: {e}")
669
+ return False
670
+ except Exception as e:
671
+ logger.error(f"Error during stream: {e}")
672
+ return False
348
673
 
349
- if response_text == "Sent":
350
- print(f"Success: {response_text} - {stream_payload}")
351
- break
352
- else:
353
- print(f"Error sending: {stream_payload}")
354
674
 
355
- except (ssl.SSLError, ConnectionError) as e:
356
- print(f"Connection error: {e}, retrying...")
357
- await asyncio.sleep(retry_delay)
358
- except Exception as e:
359
- print(f"Unexpected error: {e}, retrying...")
360
- await asyncio.sleep(retry_delay)
361
- finally:
362
- if 'writer' in locals():
363
- writer.close()
364
- await writer.wait_closed()
675
+ class Cell(BaseClient):
676
+ """Cell client implementation"""
677
+
678
+ def __init__(self, config: Optional[ClientConfig] = None):
679
+ super().__init__(config)
680
+ self.env = self._load_env()
681
+ private_key = self._load_private_key()
682
+ self._init_crypto(private_key)
683
+
684
+ self.host = self.env.get("HOST", "")
685
+ if not self.host:
686
+ logger.warning("HOST not set in environment")
687
+
688
+ def _load_private_key(self) -> Optional[ec.EllipticCurvePrivateKey]:
689
+ """Load private key from credentials folder"""
690
+ credentials_path = self.config.credentials_path
691
+ credentials_path.mkdir(parents=True, exist_ok=True)
692
+
693
+ key_path = credentials_path / "private_key.pem"
694
+
695
+ try:
696
+ with open(key_path, "rb") as f:
697
+ private_key = serialization.load_pem_private_key(
698
+ f.read(),
699
+ password=None,
700
+ backend=default_backend()
701
+ )
702
+
703
+ # Check file permissions (should be 0600)
704
+ stat = os.stat(key_path)
705
+ if stat.st_mode & 0o177:
706
+ logger.warning(
707
+ f"Private key file has insecure permissions: {oct(stat.st_mode)}"
708
+ )
709
+
710
+ logger.info("Private key loaded successfully")
711
+ return private_key
712
+ except FileNotFoundError:
713
+ logger.error(f"Private key not found at {key_path}")
714
+ return None
715
+ except Exception as e:
716
+ logger.error(f"Error loading private key: {e}")
717
+ return None
718
+
719
+ def _load_env(self) -> Dict[str, str]:
720
+ """Load environment variables from .env file"""
721
+ env_path = self.config.credentials_path / ".env"
722
+ env_data = {}
723
+
724
+ try:
725
+ with open(env_path, "r") as f:
726
+ for line in f:
727
+ line = line.strip()
728
+ if line and not line.startswith('#') and '=' in line:
729
+ key, value = line.split('=', 1)
730
+ env_data[key.strip()] = value.strip()
731
+ logger.info("Environment loaded successfully")
732
+ return env_data
733
+ except FileNotFoundError:
734
+ logger.error(f"Environment file not found at {env_path}")
735
+ return {}
736
+ except Exception as e:
737
+ logger.error(f"Error loading environment: {e}")
738
+ return {}
739
+
740
+ async def __aenter__(self):
741
+ await self._network_client.__aenter__()
742
+ return self
743
+
744
+ async def __aexit__(self, *args):
745
+ await self._network_client.__aexit__(*args)