neuronum 8.4.0__py3-none-any.whl → 10.0.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.

Potentially problematic release.


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

neuronum/neuronum.py CHANGED
@@ -1,338 +1,745 @@
1
1
  import aiohttp
2
- from typing import AsyncGenerator
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
8
+ import ssl
7
9
  import os
10
+ import time
11
+ import logging
8
12
  from pathlib import Path
9
- from websockets.exceptions import ConnectionClosed
13
+ from dataclasses import dataclass
14
+ from websockets.exceptions import ConnectionClosed, WebSocketException
10
15
  from cryptography.hazmat.primitives.asymmetric import ec
11
16
  from cryptography.hazmat.primitives import serialization, hashes
12
17
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM
13
18
  from cryptography.hazmat.primitives.kdf.hkdf import HKDF
14
19
  from cryptography.hazmat.backends import default_backend
20
+ from abc import ABC, abstractmethod
15
21
 
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__)
16
28
 
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.host = self._load_host()
24
- self.network = self._load_network()
25
- self.synapse = self._load_synapse()
26
- self.password = self._load_password()
27
- self._private_key = self._load_private_key()
28
- self._public_key = self._load_public_key()
29
29
 
30
-
31
- def to_dict(self) -> dict:
32
- return {
33
- "host": self.host,
34
- "password": self.password,
35
- "synapse": self.synapse
36
- }
30
+ # Custom Exceptions
31
+ class NeuronumError(Exception):
32
+ """Base exception for Neuronum errors"""
33
+ pass
37
34
 
38
35
 
39
- def _load_private_key(self):
40
- try:
41
- with open(self.private_key_path, "rb") as f:
42
- private_key = serialization.load_pem_private_key(
43
- f.read(),
44
- password=None,
45
- backend=default_backend()
46
- )
47
- return private_key
48
- except FileNotFoundError:
49
- print(f"Private key file not found at {self.private_key_path}.")
50
- return None
51
-
36
+ class AuthenticationError(NeuronumError):
37
+ """Raised when authentication fails"""
38
+ pass
52
39
 
53
- def _load_host(self):
54
- credentials_folder_path = Path.home() / ".neuronum"
55
- env_path = credentials_folder_path / ".env"
56
40
 
57
- env_data = {}
41
+ class EncryptionError(NeuronumError):
42
+ """Raised when encryption/decryption fails"""
43
+ pass
58
44
 
59
- try:
60
- with open(env_path, "r") as f:
61
- for line in f:
62
- key, value = line.strip().split("=")
63
- env_data[key] = value
64
45
 
65
- host = env_data.get("HOST", "")
66
- return host
67
- except FileNotFoundError:
68
- print(f"Cell Host not found")
69
- return None
70
-
46
+ class CellNotFoundError(NeuronumError):
47
+ """Raised when a cell cannot be found"""
48
+ pass
71
49
 
72
- def _load_password(self):
73
- credentials_folder_path = Path.home() / ".neuronum"
74
- env_path = credentials_folder_path / ".env"
75
50
 
76
- env_data = {}
51
+ class NetworkError(NeuronumError):
52
+ """Raised when network operations fail"""
53
+ pass
77
54
 
78
- try:
79
- with open(env_path, "r") as f:
80
- for line in f:
81
- key, value = line.strip().split("=")
82
- env_data[key] = value
83
55
 
84
- host = env_data.get("PASSWORD", "")
85
- return host
86
- except FileNotFoundError:
87
- print(f"Cell Password not found")
88
- return None
89
-
90
- def _load_synapse(self):
91
- credentials_folder_path = Path.home() / ".neuronum"
92
- env_path = credentials_folder_path / ".env"
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
93
67
 
94
- env_data = {}
95
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
+
96
81
  try:
97
- with open(env_path, "r") as f:
98
- for line in f:
99
- key, value = line.strip().split("=")
100
- env_data[key] = value
101
-
102
- host = env_data.get("SYNAPSE", "")
103
- return host
104
- except FileNotFoundError:
105
- print(f"Cell Synapse not found")
106
- return None
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")
107
92
 
108
- def _load_network(self):
109
- credentials_folder_path = Path.home() / ".neuronum"
110
- env_path = credentials_folder_path / ".env"
111
-
112
- env_data = {}
113
-
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"""
114
101
  try:
115
- with open(env_path, "r") as f:
116
- for line in f:
117
- key, value = line.strip().split("=")
118
- env_data[key] = value
119
-
120
- host = env_data.get("NETWORK", "")
121
- return host
122
- except FileNotFoundError:
123
- print(f"Cell Network not found")
124
- return None
125
-
126
-
127
- def _load_public_key(self):
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"""
128
124
  try:
129
- with open(self.public_key_path, "rb") as f:
130
- public_key = serialization.load_pem_public_key(
131
- f.read(),
132
- backend=default_backend()
133
- )
134
- return public_key
135
- except FileNotFoundError:
136
- print(f"Public key file not found at {self.public_key_path}. Deriving from private key.")
137
- if self._private_key:
138
- return self._private_key.public_key()
139
- else:
140
- return None
141
-
142
-
143
- def get_public_key_jwk(self):
144
- public_key = self._load_public_key()
145
- if not public_key:
146
- print("Public key not loaded. Cannot generate JWK.")
147
- return None
148
-
149
- public_numbers = public_key.public_numbers()
150
-
151
- x_bytes = public_numbers.x.to_bytes((public_numbers.x.bit_length() + 7) // 8, 'big')
152
- y_bytes = public_numbers.y.to_bytes((public_numbers.y.bit_length() + 7) // 8, 'big')
153
-
154
- return {
155
- "kty": "EC",
156
- "crv": "P-256",
157
- "x": base64.urlsafe_b64encode(x_bytes).rstrip(b'=').decode('utf-8'),
158
- "y": base64.urlsafe_b64encode(y_bytes).rstrip(b'=').decode('utf-8')
159
- }
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}")
160
152
 
161
-
162
- def _decrypt_with_ecdh_aesgcm(self, ephemeral_public_key_bytes, nonce, ciphertext):
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
+
163
163
  try:
164
164
  ephemeral_public_key = ec.EllipticCurvePublicKey.from_encoded_point(
165
165
  ec.SECP256R1(), ephemeral_public_key_bytes
166
166
  )
167
-
168
167
  shared_secret = self._private_key.exchange(ec.ECDH(), ephemeral_public_key)
169
-
170
168
  derived_key = HKDF(
171
- algorithm=hashes.SHA256(),
172
- length=32,
173
- salt=None,
169
+ algorithm=hashes.SHA256(),
170
+ length=32,
171
+ salt=None,
174
172
  info=b'handshake data'
175
173
  ).derive(shared_secret)
176
-
174
+
177
175
  aesgcm = AESGCM(derived_key)
178
176
  plaintext_bytes = aesgcm.decrypt(nonce, ciphertext, None)
179
177
  return json.loads(plaintext_bytes.decode())
180
-
181
178
  except Exception as e:
182
- print(f"Decryption failed: {e}")
179
+ logger.error("Decryption failed", exc_info=True)
180
+ raise EncryptionError(f"Decryption failed: {e}")
181
+
182
+
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
+
183
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
+
227
+ try:
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")
250
+ except Exception as e:
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()
184
257
 
185
258
 
186
- async def sync(self) -> AsyncGenerator[str, None]:
187
- full_url = f"wss://{self.network}/sync/{self.node_id}"
188
- auth_payload = {
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
+
288
+ try:
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}")
302
+ except Exception as e:
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)
320
+
321
+
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 {
189
364
  "host": self.host,
190
- "password": self.password,
191
- "synapse": self.synapse,
365
+ "signed_message": signed_message,
366
+ "message": message
192
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"
409
+ payload = {"cell": self.to_dict()}
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
+
444
+ url = f"https://{self.network}/api/tx_response/{transmitter_id}"
445
+
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()}
449
+
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}"
477
+ payload = {"cell": self.to_dict()}
478
+
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")
496
+ return response_data
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
193
542
  while True:
194
543
  try:
195
- 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:
196
547
  await ws.send(json.dumps(auth_payload))
197
- print("Node syncing...")
548
+ logger.info(f"Connected and authenticated to {cell}")
549
+ retry_count = 0 # Reset on successful connection
550
+
198
551
  while True:
199
552
  try:
200
- raw_operation = await ws.recv()
553
+ raw_operation = await asyncio.wait_for(
554
+ ws.recv(),
555
+ timeout=self.config.timeout
556
+ )
201
557
  operation = json.loads(raw_operation)
202
558
 
203
559
  if "encrypted" in operation.get("data", {}):
204
560
  encrypted_data = operation["data"]["encrypted"]
205
561
 
206
- ephemeral_public_key_b64 = encrypted_data["ephemeralPublicKey"]
207
- ephemeral_public_key_b64 += '=' * ((4 - len(ephemeral_public_key_b64) % 4) % 4)
208
- ephemeral_public_key_bytes = base64.urlsafe_b64decode(ephemeral_public_key_b64)
209
-
210
- nonce_b64 = encrypted_data["nonce"]
211
- nonce_b64 += '=' * ((4 - len(nonce_b64) % 4) % 4)
212
- nonce = base64.urlsafe_b64decode(nonce_b64)
213
-
214
- ciphertext_b64 = encrypted_data["ciphertext"]
215
- ciphertext_b64 += '=' * ((4 - len(ciphertext_b64) % 4) % 4)
216
- ciphertext = base64.urlsafe_b64decode(ciphertext_b64)
217
-
218
- decrypted_data = self._decrypt_with_ecdh_aesgcm(
219
- ephemeral_public_key_bytes, nonce, ciphertext
220
- )
221
-
222
- 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
+
223
577
  operation["data"].update(decrypted_data)
224
578
  operation["data"].pop("encrypted")
225
579
  yield operation
226
- else:
227
- print("Failed to decrypt incoming data. Skipping...")
580
+ except EncryptionError:
581
+ logger.error("Failed to decrypt operation")
228
582
  else:
229
- yield operation
583
+ logger.warning("Received unencrypted data")
584
+
230
585
  except asyncio.TimeoutError:
231
- print("No data received. Continuing...")
586
+ await ws.ping()
587
+ continue
232
588
  except ConnectionClosed as e:
233
- if e.code == 1000:
234
- print(f"WebSocket closed cleanly (code 1000). Reconnecting...")
235
- else:
236
- print(f"Connection closed with error code {e.code}: {e.reason}. Reconnecting...")
589
+ logger.warning(f"Connection closed: {e.code} - {e.reason}")
237
590
  break
238
591
  except Exception as e:
239
- print(f"Unexpected error in recv loop: {e}")
592
+ logger.error(f"Error in receive loop: {e}")
240
593
  break
241
- except websockets.exceptions.WebSocketException as e:
242
- print(f"WebSocket error occurred: {e}. Retrying in 5 seconds...")
594
+
595
+ except WebSocketException as e:
596
+ logger.error(f"WebSocket error: {e}")
243
597
  except Exception as e:
244
- print(f"General error occurred: {e}. Retrying in 5 seconds...")
245
- await asyncio.sleep(3)
246
-
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}")
657
+
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}")
665
+
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
247
673
 
248
- async def list_nodes(self):
249
- full_url = f"https://{self.network}/api/list_nodes"
250
- list_nodes_payload = {
251
- "cell": self.to_dict()
252
- }
253
- async with aiohttp.ClientSession() as session:
254
- try:
255
- async with session.post(full_url, json=list_nodes_payload) as response:
256
- response.raise_for_status()
257
- data = await response.json()
258
- return data.get("Nodes", [])
259
- except aiohttp.ClientError as e:
260
- print(f"Error sending request: {e}")
261
- except Exception as e:
262
- print(f"Unexpected error: {e}")
263
-
264
674
 
265
- def _load_public_key_from_jwk(self, jwk):
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
+
266
695
  try:
267
- print(jwk)
268
- x = base64.urlsafe_b64decode(jwk['x'] + '==')
269
- y = base64.urlsafe_b64decode(jwk['y'] + '==')
270
- public_numbers = ec.EllipticCurvePublicNumbers(
271
- int.from_bytes(x, 'big'),
272
- int.from_bytes(y, 'big'),
273
- ec.SECP256R1()
274
- )
275
- return public_numbers.public_key(default_backend())
276
- except (KeyError, ValueError, TypeError) as e:
277
- print(f"Error loading public key from JWK string: {e}")
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}")
278
717
  return None
279
-
280
-
281
- def _encrypt_with_ecdh_aesgcm(self, public_key, plaintext_dict):
282
- ephemeral_private = ec.generate_private_key(ec.SECP256R1())
283
- ephemeral_public = ephemeral_private.public_key()
284
- shared_secret = ephemeral_private.exchange(ec.ECDH(), public_key)
285
- derived_key = HKDF(
286
- algorithm=hashes.SHA256(),
287
- length=32,
288
- salt=None,
289
- info=b'handshake data'
290
- ).derive(shared_secret)
291
- aesgcm = AESGCM(derived_key)
292
- nonce = os.urandom(12)
293
- plaintext_bytes = json.dumps(plaintext_dict).encode()
294
- ciphertext = aesgcm.encrypt(nonce, plaintext_bytes, None)
295
- ephemeral_public_bytes = ephemeral_public.public_bytes(
296
- serialization.Encoding.X962,
297
- serialization.PublicFormat.UncompressedPoint
298
- )
299
- return {
300
- 'ciphertext': base64.b64encode(ciphertext).decode(),
301
- 'nonce': base64.b64encode(nonce).decode(),
302
- 'ephemeralPublicKey': base64.b64encode(ephemeral_public_bytes).decode()
303
- }
304
718
 
305
-
306
- async def tx_response(self, transmitter_id: str, data: dict, client_public_key_str):
307
- if isinstance(client_public_key_str, str):
308
- try:
309
- client_public_key_jwk = json.loads(client_public_key_str)
310
- except json.JSONDecodeError:
311
- print("Failed to decode client public key from string. Aborting response.")
312
- return
313
- elif isinstance(client_public_key_str, dict):
314
- client_public_key_jwk = client_public_key_str
315
- else:
316
- print("Invalid type for client public key. Expected str or dict. Aborting response.")
317
- return
318
- public_key = self._load_public_key_from_jwk(client_public_key_jwk)
319
- if not public_key:
320
- print("Failed to load public key. Aborting response.")
321
- return
322
- encrypted_payload = self._encrypt_with_ecdh_aesgcm(public_key, data)
323
- url = f"https://{self.network}/api/tx_response/{transmitter_id}"
324
- tx_response = {
325
- "data": encrypted_payload,
326
- "cell": self.to_dict()
327
- }
328
- async with aiohttp.ClientSession() as session:
329
- try:
330
- for _ in range(2):
331
- async with session.post(url, json=tx_response) as response:
332
- response.raise_for_status()
333
- data = await response.json()
334
- print(data["message"])
335
- except aiohttp.ClientError as e:
336
- print(f"Error sending request: {e}")
337
- except Exception as e:
338
- print(f"Unexpected error: {e}")
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)