neuronum 8.4.0__tar.gz → 9.0.0__tar.gz
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-8.4.0 → neuronum-9.0.0}/PKG-INFO +1 -1
- {neuronum-8.4.0 → neuronum-9.0.0}/cli/main.py +5 -2
- neuronum-9.0.0/neuronum/neuronum.py +364 -0
- {neuronum-8.4.0 → neuronum-9.0.0}/neuronum.egg-info/PKG-INFO +1 -1
- {neuronum-8.4.0 → neuronum-9.0.0}/setup.py +1 -1
- neuronum-8.4.0/neuronum/neuronum.py +0 -338
- {neuronum-8.4.0 → neuronum-9.0.0}/LICENSE.md +0 -0
- {neuronum-8.4.0 → neuronum-9.0.0}/README.md +0 -0
- {neuronum-8.4.0 → neuronum-9.0.0}/cli/__init__.py +0 -0
- {neuronum-8.4.0 → neuronum-9.0.0}/neuronum/__init__.py +0 -0
- {neuronum-8.4.0 → neuronum-9.0.0}/neuronum.egg-info/SOURCES.txt +0 -0
- {neuronum-8.4.0 → neuronum-9.0.0}/neuronum.egg-info/dependency_links.txt +0 -0
- {neuronum-8.4.0 → neuronum-9.0.0}/neuronum.egg-info/entry_points.txt +0 -0
- {neuronum-8.4.0 → neuronum-9.0.0}/neuronum.egg-info/requires.txt +0 -0
- {neuronum-8.4.0 → neuronum-9.0.0}/neuronum.egg-info/top_level.txt +0 -0
- {neuronum-8.4.0 → neuronum-9.0.0}/setup.cfg +0 -0
|
@@ -359,7 +359,7 @@ async def main():
|
|
|
359
359
|
data = transmitter.get("data")
|
|
360
360
|
transmitter_id = transmitter.get("transmitter_id")
|
|
361
361
|
client = transmitter.get("operator")
|
|
362
|
-
client_public_key = data.get("publicKey")
|
|
362
|
+
client_public_key = data.get("publicKey", {{}})
|
|
363
363
|
action = data.get("action")
|
|
364
364
|
|
|
365
365
|
response_data = {{}}
|
|
@@ -373,7 +373,10 @@ async def main():
|
|
|
373
373
|
"html": html_content
|
|
374
374
|
}}
|
|
375
375
|
|
|
376
|
-
|
|
376
|
+
if not client_public_key:
|
|
377
|
+
await node.tx_response(transmitter_id, response_data, encrypted=False)
|
|
378
|
+
else:
|
|
379
|
+
await node.tx_response(transmitter_id, response_data, client_public_key)
|
|
377
380
|
|
|
378
381
|
asyncio.run(main())
|
|
379
382
|
""")
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import aiohttp
|
|
2
|
+
from typing import Optional, AsyncGenerator, Union
|
|
3
|
+
import websockets
|
|
4
|
+
import json
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import os
|
|
8
|
+
import ssl
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from websockets.exceptions import ConnectionClosed
|
|
11
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
12
|
+
from cryptography.hazmat.primitives import serialization, hashes
|
|
13
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
14
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
15
|
+
from cryptography.hazmat.backends import default_backend
|
|
16
|
+
|
|
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()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict:
|
|
33
|
+
return {
|
|
34
|
+
"host": self.host,
|
|
35
|
+
"password": self.password,
|
|
36
|
+
"synapse": self.synapse
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
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
|
+
|
|
54
|
+
|
|
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
|
+
|
|
63
|
+
|
|
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
|
+
|
|
72
|
+
|
|
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
|
+
|
|
88
|
+
|
|
89
|
+
def _decrypt_with_ecdh_aesgcm(self, ephemeral_public_key_bytes, nonce, ciphertext):
|
|
90
|
+
try:
|
|
91
|
+
ephemeral_public_key = ec.EllipticCurvePublicKey.from_encoded_point(
|
|
92
|
+
ec.SECP256R1(), ephemeral_public_key_bytes
|
|
93
|
+
)
|
|
94
|
+
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)
|
|
96
|
+
aesgcm = AESGCM(derived_key)
|
|
97
|
+
plaintext_bytes = aesgcm.decrypt(nonce, ciphertext, None)
|
|
98
|
+
return json.loads(plaintext_bytes.decode())
|
|
99
|
+
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
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _load_public_key_from_jwk(self, jwk):
|
|
123
|
+
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())
|
|
132
|
+
except Exception as e:
|
|
133
|
+
print(f"Error loading public key from JWK: {e}")
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _load_public_key_from_pem(self, pem_string: str):
|
|
138
|
+
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
|
|
143
|
+
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
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def list_nodes(self):
|
|
179
|
+
full_url = f"https://{self.network}/api/list_nodes"
|
|
180
|
+
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):
|
|
186
|
+
url = f"https://{self.network}/api/tx_response/{transmitter_id}"
|
|
187
|
+
|
|
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()}
|
|
202
|
+
|
|
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}"
|
|
208
|
+
payload = {"cell": self.to_dict()}
|
|
209
|
+
|
|
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:
|
|
236
|
+
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
|
+
}
|
|
246
|
+
while True:
|
|
247
|
+
try:
|
|
248
|
+
async with websockets.connect(full_url) as ws:
|
|
249
|
+
await ws.send(json.dumps(auth_payload))
|
|
250
|
+
print("Node syncing...")
|
|
251
|
+
while True:
|
|
252
|
+
try:
|
|
253
|
+
raw_operation = await ws.recv()
|
|
254
|
+
operation = json.loads(raw_operation)
|
|
255
|
+
|
|
256
|
+
if "encrypted" in operation.get("data", {}):
|
|
257
|
+
encrypted_data = operation["data"]["encrypted"]
|
|
258
|
+
|
|
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:
|
|
276
|
+
operation["data"].update(decrypted_data)
|
|
277
|
+
operation["data"].pop("encrypted")
|
|
278
|
+
yield operation
|
|
279
|
+
else:
|
|
280
|
+
print("Failed to decrypt incoming data. Skipping...")
|
|
281
|
+
else:
|
|
282
|
+
yield operation
|
|
283
|
+
except asyncio.TimeoutError:
|
|
284
|
+
print("No data received. Continuing...")
|
|
285
|
+
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...")
|
|
290
|
+
break
|
|
291
|
+
except Exception as e:
|
|
292
|
+
print(f"Unexpected error in recv loop: {e}")
|
|
293
|
+
break
|
|
294
|
+
except websockets.exceptions.WebSocketException as e:
|
|
295
|
+
print(f"WebSocket error occurred: {e}. Retrying in 5 seconds...")
|
|
296
|
+
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
|
|
330
|
+
|
|
331
|
+
stream_payload = {
|
|
332
|
+
"label": label,
|
|
333
|
+
"data": data.copy()
|
|
334
|
+
}
|
|
335
|
+
|
|
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()
|
|
348
|
+
|
|
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
|
+
|
|
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()
|
|
@@ -1,338 +0,0 @@
|
|
|
1
|
-
import aiohttp
|
|
2
|
-
from typing import AsyncGenerator
|
|
3
|
-
import websockets
|
|
4
|
-
import json
|
|
5
|
-
import asyncio
|
|
6
|
-
import base64
|
|
7
|
-
import os
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from websockets.exceptions import ConnectionClosed
|
|
10
|
-
from cryptography.hazmat.primitives.asymmetric import ec
|
|
11
|
-
from cryptography.hazmat.primitives import serialization, hashes
|
|
12
|
-
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
13
|
-
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
14
|
-
from cryptography.hazmat.backends import default_backend
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
def to_dict(self) -> dict:
|
|
32
|
-
return {
|
|
33
|
-
"host": self.host,
|
|
34
|
-
"password": self.password,
|
|
35
|
-
"synapse": self.synapse
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
def _load_host(self):
|
|
54
|
-
credentials_folder_path = Path.home() / ".neuronum"
|
|
55
|
-
env_path = credentials_folder_path / ".env"
|
|
56
|
-
|
|
57
|
-
env_data = {}
|
|
58
|
-
|
|
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
|
-
|
|
65
|
-
host = env_data.get("HOST", "")
|
|
66
|
-
return host
|
|
67
|
-
except FileNotFoundError:
|
|
68
|
-
print(f"Cell Host not found")
|
|
69
|
-
return None
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def _load_password(self):
|
|
73
|
-
credentials_folder_path = Path.home() / ".neuronum"
|
|
74
|
-
env_path = credentials_folder_path / ".env"
|
|
75
|
-
|
|
76
|
-
env_data = {}
|
|
77
|
-
|
|
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
|
-
|
|
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"
|
|
93
|
-
|
|
94
|
-
env_data = {}
|
|
95
|
-
|
|
96
|
-
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
|
|
107
|
-
|
|
108
|
-
def _load_network(self):
|
|
109
|
-
credentials_folder_path = Path.home() / ".neuronum"
|
|
110
|
-
env_path = credentials_folder_path / ".env"
|
|
111
|
-
|
|
112
|
-
env_data = {}
|
|
113
|
-
|
|
114
|
-
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):
|
|
128
|
-
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
|
-
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def _decrypt_with_ecdh_aesgcm(self, ephemeral_public_key_bytes, nonce, ciphertext):
|
|
163
|
-
try:
|
|
164
|
-
ephemeral_public_key = ec.EllipticCurvePublicKey.from_encoded_point(
|
|
165
|
-
ec.SECP256R1(), ephemeral_public_key_bytes
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
shared_secret = self._private_key.exchange(ec.ECDH(), ephemeral_public_key)
|
|
169
|
-
|
|
170
|
-
derived_key = HKDF(
|
|
171
|
-
algorithm=hashes.SHA256(),
|
|
172
|
-
length=32,
|
|
173
|
-
salt=None,
|
|
174
|
-
info=b'handshake data'
|
|
175
|
-
).derive(shared_secret)
|
|
176
|
-
|
|
177
|
-
aesgcm = AESGCM(derived_key)
|
|
178
|
-
plaintext_bytes = aesgcm.decrypt(nonce, ciphertext, None)
|
|
179
|
-
return json.loads(plaintext_bytes.decode())
|
|
180
|
-
|
|
181
|
-
except Exception as e:
|
|
182
|
-
print(f"Decryption failed: {e}")
|
|
183
|
-
return None
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
async def sync(self) -> AsyncGenerator[str, None]:
|
|
187
|
-
full_url = f"wss://{self.network}/sync/{self.node_id}"
|
|
188
|
-
auth_payload = {
|
|
189
|
-
"host": self.host,
|
|
190
|
-
"password": self.password,
|
|
191
|
-
"synapse": self.synapse,
|
|
192
|
-
}
|
|
193
|
-
while True:
|
|
194
|
-
try:
|
|
195
|
-
async with websockets.connect(full_url) as ws:
|
|
196
|
-
await ws.send(json.dumps(auth_payload))
|
|
197
|
-
print("Node syncing...")
|
|
198
|
-
while True:
|
|
199
|
-
try:
|
|
200
|
-
raw_operation = await ws.recv()
|
|
201
|
-
operation = json.loads(raw_operation)
|
|
202
|
-
|
|
203
|
-
if "encrypted" in operation.get("data", {}):
|
|
204
|
-
encrypted_data = operation["data"]["encrypted"]
|
|
205
|
-
|
|
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:
|
|
223
|
-
operation["data"].update(decrypted_data)
|
|
224
|
-
operation["data"].pop("encrypted")
|
|
225
|
-
yield operation
|
|
226
|
-
else:
|
|
227
|
-
print("Failed to decrypt incoming data. Skipping...")
|
|
228
|
-
else:
|
|
229
|
-
yield operation
|
|
230
|
-
except asyncio.TimeoutError:
|
|
231
|
-
print("No data received. Continuing...")
|
|
232
|
-
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...")
|
|
237
|
-
break
|
|
238
|
-
except Exception as e:
|
|
239
|
-
print(f"Unexpected error in recv loop: {e}")
|
|
240
|
-
break
|
|
241
|
-
except websockets.exceptions.WebSocketException as e:
|
|
242
|
-
print(f"WebSocket error occurred: {e}. Retrying in 5 seconds...")
|
|
243
|
-
except Exception as e:
|
|
244
|
-
print(f"General error occurred: {e}. Retrying in 5 seconds...")
|
|
245
|
-
await asyncio.sleep(3)
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
265
|
-
def _load_public_key_from_jwk(self, jwk):
|
|
266
|
-
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}")
|
|
278
|
-
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
|
-
|
|
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}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|