neuronum 9.0.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.
- cli/main.py +229 -945
- neuronum/__init__.py +1 -1
- neuronum/neuronum.py +671 -290
- neuronum-10.0.0.dist-info/METADATA +157 -0
- neuronum-10.0.0.dist-info/RECORD +10 -0
- neuronum-9.0.0.dist-info/METADATA +0 -124
- neuronum-9.0.0.dist-info/RECORD +0 -10
- {neuronum-9.0.0.dist-info → neuronum-10.0.0.dist-info}/WHEEL +0 -0
- {neuronum-9.0.0.dist-info → neuronum-10.0.0.dist-info}/entry_points.txt +0 -0
- {neuronum-9.0.0.dist-info → neuronum-10.0.0.dist-info}/licenses/LICENSE.md +0 -0
- {neuronum-9.0.0.dist-info → neuronum-10.0.0.dist-info}/top_level.txt +0 -0
neuronum/neuronum.py
CHANGED
|
@@ -1,364 +1,745 @@
|
|
|
1
1
|
import aiohttp
|
|
2
|
-
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
async def activate_tx(
|
|
207
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
280
|
-
|
|
580
|
+
except EncryptionError:
|
|
581
|
+
logger.error("Failed to decrypt operation")
|
|
281
582
|
else:
|
|
282
|
-
|
|
583
|
+
logger.warning("Received unencrypted data")
|
|
584
|
+
|
|
283
585
|
except asyncio.TimeoutError:
|
|
284
|
-
|
|
586
|
+
await ws.ping()
|
|
587
|
+
continue
|
|
285
588
|
except ConnectionClosed as e:
|
|
286
|
-
|
|
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
|
-
|
|
592
|
+
logger.error(f"Error in receive loop: {e}")
|
|
293
593
|
break
|
|
294
|
-
|
|
295
|
-
|
|
594
|
+
|
|
595
|
+
except WebSocketException as e:
|
|
596
|
+
logger.error(f"WebSocket error: {e}")
|
|
296
597
|
except Exception as e:
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
"
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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)
|