distributed-state-network 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ from distributed_state_network.handler import DSNodeServer
2
+ from distributed_state_network.dsnode import DSNode
3
+ from distributed_state_network.objects.config import DSNodeConfig
4
+ from distributed_state_network.objects.endpoint import Endpoint
@@ -0,0 +1,10 @@
1
+ import sys
2
+ from util.aes import generate_aes_key
3
+
4
+ if len(sys.argv) < 2:
5
+ raise Exception("Usage: python create_key.py [output_key_file_location]")
6
+
7
+ location = sys.argv[1]
8
+
9
+ with open(location, 'wb') as f:
10
+ f.write(generate_aes_key())
@@ -0,0 +1,263 @@
1
+ import os
2
+ import time
3
+ import json
4
+ import logging
5
+
6
+ import requests
7
+ import threading
8
+ from requests import RequestException
9
+ from typing import Dict, Tuple, List, Optional
10
+
11
+ from distributed_state_network.objects.endpoint import Endpoint
12
+ from distributed_state_network.objects.hello_packet import HelloPacket
13
+ from distributed_state_network.objects.peers_packet import PeersPacket
14
+ from distributed_state_network.objects.state_packet import StatePacket
15
+ from distributed_state_network.objects.config import DSNodeConfig
16
+
17
+ from distributed_state_network.util import get_dict_hash
18
+ from distributed_state_network.util.key_manager import CertManager, CredentialManager
19
+ from distributed_state_network.util.aes import aes_encrypt, aes_decrypt, generate_aes_key
20
+
21
+ TICK_INTERVAL = 3
22
+
23
+ class DSNode:
24
+ config: DSNodeConfig
25
+ address_book: Dict[str, Endpoint]
26
+ node_states: Dict[str, StatePacket]
27
+ shutting_down: bool = False
28
+
29
+ def __init__(
30
+ self,
31
+ config: DSNodeConfig,
32
+ version: str,
33
+ ):
34
+ self.config = config
35
+ self.version = version
36
+
37
+ self.cert_manager = CertManager(config.node_id)
38
+ self.cred_manager = CredentialManager(config.node_id)
39
+
40
+ self.cert_manager.generate_keys()
41
+ self.cred_manager.generate_keys()
42
+
43
+ self.node_states = {
44
+ self.config.node_id: StatePacket.create(self.config.node_id, time.time(), self.cred_manager.my_private(), { })
45
+ }
46
+
47
+ self.address_book = {
48
+ self.config.node_id: Endpoint("127.0.0.1", config.port)
49
+ }
50
+
51
+ self.logger = logging.getLogger("DSN: " + config.node_id)
52
+ if not os.path.exists(config.aes_key_file):
53
+ raise Exception(f"Could not find aes key file in {config.aes_key_file}")
54
+ threading.Thread(target=self.network_tick).start()
55
+
56
+ def get_aes_key(self):
57
+ with open(self.config.aes_key_file, 'rb') as f:
58
+ return f.read()
59
+
60
+ def network_tick(self):
61
+ time.sleep(TICK_INTERVAL)
62
+ if self.shutting_down:
63
+ self.logger.info(f"Shutting down node")
64
+ return
65
+ self.test_connections()
66
+ threading.Thread(target=self.network_tick).start()
67
+
68
+ def test_connections(self):
69
+ def remove(node_id: str):
70
+ if node_id in self.node_states:
71
+ del self.node_states[node_id]
72
+ self.logger.info(f"PING failed for {node_id}, disconnecting...")
73
+ for node_id in self.node_states.copy().keys():
74
+ if not node_id in self.node_states or node_id == self.config.node_id:
75
+ continue
76
+ try:
77
+ if self.shutting_down:
78
+ return
79
+ self.send_ping(node_id)
80
+ except RequestException:
81
+ if node_id in self.node_states: # double check if something has changed since the ping request started
82
+ remove(node_id)
83
+
84
+ def send_request_to_node(self, node_id: str, path: str, payload: bytes, verify) -> Tuple[requests.Response, bytes]:
85
+ con = self.connection_from_node(node_id)
86
+ return self.send_request(con, path, payload, verify)
87
+
88
+ def send_request(self, con: Endpoint, path: str, payload: bytes, verify, retries: int = 0) -> Tuple[requests.Response, bytes]:
89
+ try:
90
+ # Always send a ping first to throw an error if https validation does not work
91
+ requests.post(f'https://{con.to_string()}/ping', data=self.encrypt_data(payload), verify=verify, timeout=2)
92
+ res = requests.post(f'https://{con.to_string()}/{path}', data=self.encrypt_data(payload), verify=verify, timeout=2)
93
+ except Exception as e:
94
+ self.logger.error(e)
95
+ time.sleep(1)
96
+ if retries < 2:
97
+ return self.send_request(con, path, payload, verify, retries + 1)
98
+ else:
99
+ raise RequestException(f'{path.upper()} => {con.to_string()} (no response)')
100
+ return self.parse_response(con, path, res)
101
+
102
+ def parse_response(self, con: Endpoint, path: str, res: requests.Response) -> Tuple[requests.Response, bytes]:
103
+ if res.status_code != 200:
104
+ raise RequestException(f'{path.upper()} => {con.to_string()} (status code {res.status_code})')
105
+
106
+ decrypted_data = b''
107
+ if len(res.content) > 0:
108
+ try:
109
+ decrypted_data = self.decrypt_data(res.content)
110
+ except Exception as e:
111
+ raise RequestException(f'{path.upper()} => {con.to_string()} (cannot decrypt response)')
112
+
113
+ return res, decrypted_data
114
+
115
+ def encrypt_data(self, data: bytes) -> bytes:
116
+ return aes_encrypt(self.get_aes_key(), data)
117
+
118
+ def decrypt_data(self, data: bytes) -> bytes:
119
+ return aes_decrypt(self.get_aes_key(), data)
120
+
121
+ def request_peers(self, node_id: str):
122
+ pkt = PeersPacket(self.config.node_id, None, { })
123
+ pkt.sign(self.cred_manager.my_private())
124
+ res, content = self.send_request_to_node(node_id, 'peers', pkt.to_bytes(), self.cert_manager.public_path(node_id))
125
+ pkt = PeersPacket.from_bytes(content)
126
+ if not pkt.verify_signature(self.cred_manager.read_public(node_id)):
127
+ raise Exception("Could not verify peers packet")
128
+
129
+ for key in pkt.connections.keys():
130
+ if key == self.config.node_id:
131
+ continue
132
+
133
+ self.address_book[key] = pkt.connections[key]
134
+
135
+ if key not in self.node_states:
136
+ self.send_hello(self.address_book[key])
137
+
138
+ _, node_state = self.send_update(key)
139
+ self.handle_update(node_state)
140
+
141
+ def handle_peers(self, data: bytes):
142
+ pkt = PeersPacket.from_bytes(data)
143
+ if pkt.node_id not in self.address_book:
144
+ raise Exception(401) # Not Authorized
145
+
146
+ if not pkt.verify_signature(self.cred_manager.read_public(pkt.node_id)):
147
+ raise Exception(406) # Not Acceptable
148
+
149
+ peers = { }
150
+ for key in self.address_book.keys():
151
+ peers[key] = self.address_book[key]
152
+
153
+ pkt = PeersPacket(self.config.node_id, None, peers)
154
+ pkt.sign(self.cred_manager.my_private())
155
+ return pkt.to_bytes()
156
+
157
+ def send_hello(self, con: Endpoint):
158
+ self.logger.info(f"HELLO => {con.to_string()}")
159
+
160
+ payload = self.my_hello_packet().to_bytes()
161
+ _, content = self.send_request(con, 'hello', payload, False)
162
+ self.handle_hello(content)
163
+
164
+ pkt = HelloPacket.from_bytes(content)
165
+ return pkt.node_id
166
+
167
+ def handle_hello(self, data: bytes) -> bytes:
168
+ pkt = HelloPacket.from_bytes(data)
169
+ self.logger.info(f"Received HELLO from {pkt.node_id}")
170
+ if pkt.version != self.version:
171
+ msg = f"HELLO => {pkt.node_id} (Version mismatch \"{pkt.version}\" != \"{self.version}\")"
172
+ self.logger.error(msg)
173
+ raise Exception(505) # Version not supported
174
+
175
+ self.cert_manager.ensure_public(pkt.node_id, pkt.https_certificate)
176
+ self.cred_manager.ensure_public(pkt.node_id, pkt.ecdsa_public_key)
177
+
178
+ if pkt.node_id not in self.address_book:
179
+ self.address_book[pkt.node_id] = pkt.connection
180
+
181
+ if pkt.node_id not in self.node_states:
182
+ self.node_states[pkt.node_id] = StatePacket(pkt.node_id, 0, b'', { })
183
+
184
+ return self.my_hello_packet().to_bytes()
185
+
186
+ def my_hello_packet(self) -> HelloPacket:
187
+ pkt = HelloPacket(
188
+ self.version,
189
+ self.config.node_id,
190
+ self.my_con(),
191
+ self.cred_manager.my_public(),
192
+ None,
193
+ self.cert_manager.my_public()
194
+ )
195
+ pkt.sign(self.cred_manager.my_private())
196
+ return pkt
197
+
198
+ def send_ping(self, node_id: str):
199
+ try:
200
+ self.send_request_to_node(node_id, 'ping', b' ', verify=self.cert_manager.public_path(node_id))
201
+ except Exception as e:
202
+ raise RequestException(f'PING => {node_id}: {e}')
203
+
204
+ def send_update(self, node_id: str):
205
+ self.logger.info(f"UPDATE => {node_id}")
206
+ return self.send_request_to_node(node_id, 'update', self.my_state().to_bytes(), self.cert_manager.public_path(node_id))
207
+
208
+ def handle_update(self, data: bytes):
209
+ pkt = StatePacket.from_bytes(data)
210
+ self.logger.info(f"Received UPDATE from {pkt.node_id}")
211
+
212
+ # ignore if we accidentally sent an update to ourselves
213
+ if pkt.node_id == self.config.node_id:
214
+ raise Exception(406) # Not acceptable
215
+
216
+ # don't use packets older than last update
217
+ if pkt.node_id in self.node_states and self.node_states[pkt.node_id].last_update > pkt.last_update:
218
+ raise Exception(406) # Not acceptable
219
+
220
+ if not pkt.verify_signature(self.cred_manager.read_public(pkt.node_id)):
221
+ raise Exception(401) # Not authorized
222
+
223
+ if pkt.node_id not in self.node_states:
224
+ self.node_states[pkt.node_id] = pkt
225
+ return
226
+
227
+ if get_dict_hash(self.node_states[pkt.node_id].state_data) != get_dict_hash(pkt.state_data):
228
+ self.node_states[pkt.node_id] = pkt
229
+
230
+ return self.my_state().to_bytes()
231
+
232
+ def my_state(self):
233
+ return self.node_states[self.config.node_id]
234
+
235
+ def bootstrap(self, con: Endpoint):
236
+ bootstrap_id = self.send_hello(con)
237
+ self.address_book[bootstrap_id] = con
238
+ _, content = self.send_update(bootstrap_id)
239
+ self.handle_update(content)
240
+ self.request_peers(bootstrap_id)
241
+
242
+ def connection_from_node(self, node_id: str) -> Endpoint:
243
+ if node_id not in self.address_book:
244
+ raise Exception(f"could not find connection for {node_id}")
245
+ return self.address_book[node_id]
246
+
247
+ def update_data(self, key: str, val: str):
248
+ self.node_states[self.config.node_id].update_state(key, val, self.cred_manager.my_private())
249
+ for key in list(self.node_states.keys())[:]:
250
+ if key == self.config.node_id:
251
+ continue
252
+ self.send_update(key)
253
+
254
+ def my_con(self) -> Endpoint:
255
+ return self.connection_from_node(self.config.node_id)
256
+
257
+ def read_data(self, node_id: str, key: str) -> Optional[str]:
258
+ if key not in self.node_states[node_id].state_data.keys():
259
+ return None
260
+ return self.node_states[node_id].state_data[key]
261
+
262
+ def peers(self) -> List[str]:
263
+ return list(self.node_states.keys())
@@ -0,0 +1,114 @@
1
+ import ssl
2
+ import threading
3
+ import json
4
+ import logging
5
+ from typing import Tuple, Callable, Dict
6
+ from threading import Thread
7
+ from http.server import HTTPServer, BaseHTTPRequestHandler
8
+
9
+ from distributed_state_network.dsnode import DSNode
10
+ from distributed_state_network.objects.config import DSNodeConfig
11
+ from distributed_state_network.util.aes import generate_aes_key
12
+ from distributed_state_network.util import stop_thread
13
+
14
+ VERSION = "0.0.1"
15
+ logging.basicConfig(level=logging.INFO)
16
+
17
+ def respond_bytes(handler: BaseHTTPRequestHandler, data: bytes):
18
+ handler.send_response(200)
19
+ handler.send_header("Content-Type", "application/octet-stream")
20
+ handler.end_headers()
21
+ handler.wfile.write(data)
22
+ handler.wfile.flush()
23
+
24
+ def graceful_fail(httpd: BaseHTTPRequestHandler, body: bytes, fn: Callable):
25
+ try:
26
+ fn_result = fn(body)
27
+ if fn_result is not None:
28
+ respond_bytes(httpd, httpd.server.node.encrypt_data(fn_result))
29
+ else:
30
+ respond_bytes(httpd, b'')
31
+ except Exception as e:
32
+ httpd.server.node.logger.error(f"Sending Error: {e.args[0]}")
33
+ httpd.send_response(e.args[0])
34
+ httpd.end_headers()
35
+
36
+ class DSNodeHandler(BaseHTTPRequestHandler):
37
+ server: "NodeServer"
38
+
39
+ def do_POST(self):
40
+ content_length = int(self.headers.get('Content-Length', 0))
41
+ try:
42
+ body = self.server.node.decrypt_data(self.rfile.read(content_length))
43
+ except Exception as e:
44
+ self.server.node.logger.error(f"{self.path}: Error decrypting data, {e}")
45
+ respond_bytes(self, b'Not Authorized')
46
+ return
47
+
48
+
49
+ if self.path == "/hello":
50
+ graceful_fail(self, body, self.server.node.handle_hello)
51
+
52
+ elif self.path == "/peers":
53
+ graceful_fail(self, body, self.server.node.handle_peers)
54
+
55
+ elif self.path == "/update":
56
+ graceful_fail(self, body, self.server.node.handle_update)
57
+
58
+ elif self.path == "/ping":
59
+ respond_bytes(self, b'')
60
+
61
+ else:
62
+ self.send_response(404)
63
+ self.end_headers()
64
+
65
+ def log_message(self, format, *args):
66
+ pass
67
+
68
+ def serve(httpd):
69
+ httpd.serve_forever()
70
+
71
+ class DSNodeServer(HTTPServer):
72
+ def __init__(
73
+ self,
74
+ config: DSNodeConfig
75
+ ):
76
+ super().__init__(("127.0.0.1", config.port), DSNodeHandler)
77
+ self.config = config
78
+ self.node = DSNode(config, VERSION)
79
+ self.node.logger.info(f'Started DSNode on port {config.port}')
80
+
81
+ def stop(self):
82
+ self.node.shutting_down = True
83
+ self.shutdown()
84
+ self.socket.close()
85
+ stop_thread(self.thread)
86
+
87
+ @staticmethod
88
+ def generate_key(out_file_path: str):
89
+ key = generate_aes_key()
90
+ with open(out_file_path, 'wb') as f:
91
+ f.write(key)
92
+
93
+ @staticmethod
94
+ def start(config: DSNodeConfig) -> 'NodeServer':
95
+ n = DSNodeServer(config)
96
+ ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
97
+ public_path = n.node.cert_manager.public_path(n.config.node_id)
98
+ ssl_context.load_cert_chain(
99
+ certfile=public_path,
100
+ keyfile=public_path.replace(".crt", ".key")
101
+ )
102
+ n.socket = ssl_context.wrap_socket(n.socket, server_side=True)
103
+ n.thread = threading.Thread(target=serve, args=(n, ))
104
+ n.thread.start()
105
+
106
+ if n.config.bootstrap_nodes is not None and len(n.config.bootstrap_nodes) > 0:
107
+ for bs in n.config.bootstrap_nodes:
108
+ try:
109
+ n.node.bootstrap(bs)
110
+ break # Throws exception if connection is not made
111
+ except Exception as e:
112
+ print(e)
113
+
114
+ return n
@@ -0,0 +1,15 @@
1
+ from typing import Dict, List
2
+ from dataclasses import dataclass
3
+
4
+ from distributed_state_network.objects.endpoint import Endpoint
5
+
6
+ @dataclass(frozen=True)
7
+ class DSNodeConfig:
8
+ node_id: str
9
+ port: int
10
+ aes_key_file: str
11
+ bootstrap_nodes: List[Endpoint]
12
+
13
+ @staticmethod
14
+ def from_dict(data: Dict) -> 'DSNodeConfig':
15
+ return DSNodeConfig(data["node_id"], data["port"], data["aes_key_file"], [Endpoint.from_json(e) for e in data["bootstrap_nodes"]])
@@ -0,0 +1,36 @@
1
+ from typing import Dict
2
+ from dataclasses import dataclass
3
+ from distributed_state_network.util.byte_helper import ByteHelper
4
+
5
+ @dataclass(frozen=True)
6
+ class Endpoint:
7
+ address: str
8
+ port: int
9
+
10
+ def to_string(self):
11
+ return f"{self.address}:{self.port}"
12
+
13
+ def to_bytes(self):
14
+ bts = ByteHelper()
15
+ bts.write_string(self.address)
16
+ bts.write_int(self.port)
17
+
18
+ return bts.get_bytes()
19
+
20
+ def to_json(self):
21
+ return {
22
+ "address": self.address,
23
+ "port": self.port
24
+ }
25
+
26
+ @staticmethod
27
+ def from_bytes(data: bytes):
28
+ bts = ByteHelper(data)
29
+ address = bts.read_string()
30
+ port = bts.read_int()
31
+
32
+ return Endpoint(address, port)
33
+
34
+ @staticmethod
35
+ def from_json(data: Dict) -> 'Endpoint':
36
+ return Endpoint(data['address'], data['port'])
@@ -0,0 +1,59 @@
1
+ import json
2
+ from io import BytesIO
3
+ from typing import Dict
4
+
5
+ from distributed_state_network.objects.endpoint import Endpoint
6
+
7
+ from distributed_state_network.objects.signed_packet import SignedPacket
8
+ from distributed_state_network.util.byte_helper import ByteHelper
9
+ from distributed_state_network.util import bytes_to_int, int_to_bytes
10
+
11
+ class HelloPacket(SignedPacket):
12
+ version: str
13
+ node_id: str
14
+ connection: Endpoint
15
+ ecdsa_public_key: bytes
16
+ https_certificate: bytes
17
+
18
+ def __init__(
19
+ self,
20
+ version: str,
21
+ node_id: str,
22
+ connection: Endpoint,
23
+ ecdsa_public_key: bytes,
24
+ ecdsa_signature: bytes,
25
+ https_certificate: bytes
26
+ ):
27
+ super().__init__(ecdsa_signature)
28
+ self.version = version
29
+ self.node_id = node_id
30
+ self.connection = connection
31
+ self.ecdsa_public_key = ecdsa_public_key
32
+ self.https_certificate = https_certificate
33
+
34
+ def to_bytes(self, include_signature: bool = True):
35
+ bts = ByteHelper()
36
+ bts.write_string(self.version)
37
+ bts.write_string(self.node_id)
38
+ bts.write_bytes(self.connection.to_bytes())
39
+ bts.write_bytes(self.ecdsa_public_key)
40
+ bts.write_bytes(self.https_certificate)
41
+ if include_signature:
42
+ bts.write_bytes(self.ecdsa_signature)
43
+
44
+ return bts.get_bytes()
45
+
46
+ @staticmethod
47
+ def from_bytes(data: bytes):
48
+ bts = ByteHelper(data)
49
+ version = bts.read_string()
50
+ node_id = bts.read_string()
51
+ connection = Endpoint.from_bytes(bts.read_bytes())
52
+ ecdsa_public_key = bts.read_bytes()
53
+ https_certificate = bts.read_bytes()
54
+ ecdsa_signature = bts.read_bytes()
55
+
56
+ if version == '' or node_id == '' or ecdsa_public_key == b'' or https_certificate == b'':
57
+ raise Exception(406) # Not acceptable
58
+
59
+ return HelloPacket(version, node_id, connection, ecdsa_public_key, ecdsa_signature, https_certificate)
@@ -0,0 +1,41 @@
1
+ from typing import Dict
2
+
3
+ from distributed_state_network.objects.endpoint import Endpoint
4
+ from distributed_state_network.util.byte_helper import ByteHelper
5
+ from distributed_state_network.objects.signed_packet import SignedPacket
6
+
7
+ class PeersPacket(SignedPacket):
8
+ node_id: str
9
+ connections: Dict[str, Endpoint]
10
+
11
+ def __init__(self, node_id: str, ecdsa_signature: bytes, connections: Dict[str, Endpoint]):
12
+ super().__init__(ecdsa_signature)
13
+ self.node_id = node_id
14
+ self.connections = connections
15
+
16
+ def to_bytes(self, include_signature: bool = True) -> bytes:
17
+ bts = ByteHelper()
18
+ bts.write_string(self.node_id)
19
+ if include_signature:
20
+ bts.write_bytes(self.ecdsa_signature)
21
+
22
+ bts.write_int(len(self.connections.keys()))
23
+ for key in self.connections.keys():
24
+ bts.write_string(key)
25
+ bts.write_bytes(self.connections[key].to_bytes())
26
+
27
+ return bts.get_bytes()
28
+
29
+ @staticmethod
30
+ def from_bytes(data: bytes):
31
+ bts = ByteHelper(data)
32
+ node_id = bts.read_string()
33
+ ecdsa_signature = bts.read_bytes()
34
+
35
+ connections = { }
36
+ num_keys = bts.read_int()
37
+ for _ in range(num_keys):
38
+ key = bts.read_string()
39
+ connections[key] = Endpoint.from_bytes(bts.read_bytes())
40
+
41
+ return PeersPacket(node_id, ecdsa_signature, connections)
@@ -0,0 +1,20 @@
1
+ from distributed_state_network.util.ecdsa import verify_signature, sign_message
2
+
3
+ class SignedPacket:
4
+ ecdsa_signature: bytes
5
+
6
+ def __init__(self, ecdsa_signature: bytes):
7
+ self.ecdsa_signature = ecdsa_signature
8
+
9
+ def sign(self, private_key: bytes):
10
+ self.ecdsa_signature = sign_message(private_key, self.to_bytes(False))
11
+
12
+ def verify_signature(self, public_key: bytes):
13
+ return verify_signature(public_key, self.to_bytes(False), self.ecdsa_signature)
14
+
15
+ def to_bytes(include_signature: bool = True):
16
+ pass
17
+
18
+ @staticmethod
19
+ def from_bytes():
20
+ pass
@@ -0,0 +1,64 @@
1
+ import time
2
+ import json
3
+ from typing import Dict, Tuple
4
+
5
+ from distributed_state_network.objects.signed_packet import SignedPacket
6
+ from distributed_state_network.util.byte_helper import ByteHelper
7
+
8
+ class StatePacket(SignedPacket):
9
+ node_id: str
10
+ state_data: Dict[str, str]
11
+ last_update: float
12
+
13
+ def __init__(
14
+ self,
15
+ node_id: str,
16
+ last_update: float,
17
+ ecdsa_signature: bytes,
18
+ state_data: Dict[str, str]
19
+ ):
20
+ super().__init__(ecdsa_signature)
21
+ self.node_id = node_id
22
+ self.state_data = state_data
23
+ self.last_update = last_update
24
+
25
+ def update_state(self, key: str, val: str, private_key: bytes):
26
+ self.state_data[key] = val
27
+ self.last_update = time.time()
28
+ self.sign(private_key)
29
+
30
+ def to_bytes(self, include_signature: bool = True):
31
+ bts = ByteHelper()
32
+ bts.write_string(self.node_id)
33
+ bts.write_float(self.last_update)
34
+ if include_signature:
35
+ bts.write_bytes(self.ecdsa_signature)
36
+ bts.write_string(json.dumps(self.state_data))
37
+
38
+ return bts.get_bytes()
39
+
40
+ @staticmethod
41
+ def create(
42
+ node_id: str,
43
+ last_update: float,
44
+ ecdsa_private_key: bytes,
45
+ state_data: Dict[str, str]
46
+ ):
47
+ s = StatePacket(node_id, last_update, b'', state_data)
48
+ s.sign(ecdsa_private_key)
49
+ return s
50
+
51
+
52
+ @staticmethod
53
+ def from_bytes(data: bytes):
54
+ bts = ByteHelper(data)
55
+ node_id = bts.read_string()
56
+
57
+ if node_id == '':
58
+ raise Exception(406) # Not acceptable
59
+
60
+ last_update = bts.read_float()
61
+ ecdsa_signature = bts.read_bytes()
62
+ state_data = json.loads(bts.read_string())
63
+
64
+ return StatePacket(node_id, last_update, ecdsa_signature, state_data)
@@ -0,0 +1,34 @@
1
+ import json
2
+ import ctypes
3
+ import struct
4
+ import threading
5
+ from hashlib import sha256
6
+
7
+ def int_to_bytes(i: int) -> bytes:
8
+ return i.to_bytes(4, 'little', signed=False)
9
+
10
+ def bytes_to_int(b: bytes) -> int:
11
+ return int.from_bytes(b, 'little', signed=False)
12
+
13
+ def float_to_bytes(f: float) -> bytes:
14
+ return struct.pack(">d", f)
15
+
16
+ def bytes_to_float(b: bytes) -> float:
17
+ return struct.unpack(">d", b)[0]
18
+
19
+ def get_byte_hash(data: bytes) -> bytes:
20
+ return sha256(data).digest()
21
+
22
+ def get_hash(data: str) -> bytes:
23
+ return get_byte_hash(data.encode('utf-8'))
24
+
25
+ def get_dict_hash(data: dict) -> bytes:
26
+ return get_hash(json.dumps(data))
27
+
28
+ def stop_thread(thread: threading.Thread):
29
+ thread_id = thread.ident
30
+ res = ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id,
31
+ ctypes.py_object(SystemExit))
32
+ if res > 1: # pragma: no cover
33
+ ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id, 0)
34
+ print('Exception raise failure')
@@ -0,0 +1,45 @@
1
+ import os
2
+ from io import BytesIO
3
+
4
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
5
+ from cryptography.hazmat.backends import default_backend
6
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
7
+ from cryptography.hazmat.primitives import hashes, padding as sym_padding
8
+
9
+ def get_cipher(key: bytes, iv: bytes) -> Cipher:
10
+ return Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
11
+
12
+ def get_iv() -> bytes:
13
+ return os.urandom(16)
14
+
15
+ def generate_aes_key() -> bytes:
16
+ key = PBKDF2HMAC(
17
+ algorithm=hashes.SHA256(),
18
+ length=16,
19
+ salt=os.urandom(16),
20
+ iterations=100000,
21
+ backend=default_backend()
22
+ ).derive(os.urandom(128))
23
+ iv = get_iv()
24
+ bts = BytesIO()
25
+ bts.write(iv)
26
+ bts.write(key)
27
+ return bts.getvalue()
28
+
29
+ def aes_decrypt(key: bytes, ciphertext: bytes) -> bytes:
30
+ bts = BytesIO(key)
31
+ iv = bts.read(16)
32
+ aes_key = bts.read()
33
+ decryptor = get_cipher(aes_key, iv).decryptor()
34
+ unpadder = sym_padding.PKCS7(128).unpadder()
35
+ decrypted_text = decryptor.update(ciphertext) + decryptor.finalize()
36
+ return unpadder.update(decrypted_text) + unpadder.finalize()
37
+
38
+ def aes_encrypt(key: bytes, data: bytes) -> bytes:
39
+ bts = BytesIO(key)
40
+ iv = bts.read(16)
41
+ aes_key = bts.read()
42
+ encryptor = get_cipher(aes_key, iv).encryptor()
43
+ padder = sym_padding.PKCS7(128).padder()
44
+ padded_data = padder.update(data) + padder.finalize()
45
+ return encryptor.update(padded_data) + encryptor.finalize()
@@ -0,0 +1,36 @@
1
+ from io import BytesIO
2
+ from distributed_state_network.util import bytes_to_int, int_to_bytes, float_to_bytes, bytes_to_float
3
+
4
+ class ByteHelper:
5
+ def __init__(self, data: bytes = None):
6
+ self.bts = BytesIO(data)
7
+
8
+ def write_string(self, s: str):
9
+ encoded = s.encode('utf-8')
10
+ self.write_bytes(encoded)
11
+
12
+ def write_int(self, i: int):
13
+ self.bts.write(int_to_bytes(i))
14
+
15
+ def write_float(self, f: float):
16
+ self.bts.write(float_to_bytes(f))
17
+
18
+ def write_bytes(self, b: bytes):
19
+ self.bts.write(int_to_bytes(len(b)))
20
+ self.bts.write(b)
21
+
22
+ def read_string(self):
23
+ return self.read_bytes().decode('utf-8')
24
+
25
+ def read_int(self):
26
+ return bytes_to_int(self.bts.read(4))
27
+
28
+ def read_float(self):
29
+ return bytes_to_float(self.bts.read(8))
30
+
31
+ def read_bytes(self):
32
+ l = bytes_to_int(self.bts.read(4))
33
+ return self.bts.read(l)
34
+
35
+ def get_bytes(self):
36
+ return self.bts.getvalue()
@@ -0,0 +1,21 @@
1
+ import hashlib
2
+
3
+ from ecdsa import SigningKey, VerifyingKey, SECP256k1
4
+
5
+ def generate_key_pair():
6
+ private_key = SigningKey.generate(curve=SECP256k1)
7
+ public_key = private_key.get_verifying_key()
8
+ return public_key.to_string(), private_key.to_string()
9
+
10
+ def sign_message(private_key: bytes, message: bytes) -> bytes:
11
+ private_key = SigningKey.from_string(private_key, curve=SECP256k1)
12
+ message_hash = hashlib.sha256(message).digest()
13
+ return private_key.sign(message_hash)
14
+
15
+ def verify_signature(public_key: bytes, message: bytes, signature: bytes):
16
+ public_key_obj = VerifyingKey.from_string(public_key, curve=SECP256k1)
17
+ message_hash = hashlib.sha256(message).digest()
18
+ try:
19
+ return public_key_obj.verify(signature, message_hash)
20
+ except Exception:
21
+ return False
@@ -0,0 +1,54 @@
1
+ import ipaddress
2
+ from datetime import datetime, timedelta
3
+
4
+ from cryptography import x509
5
+ from cryptography.x509 import IPAddress
6
+ from cryptography.x509.oid import NameOID
7
+ from cryptography.hazmat.primitives import hashes
8
+ from cryptography.hazmat.backends import default_backend
9
+ from cryptography.hazmat.primitives.asymmetric import rsa
10
+ from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, NoEncryption
11
+
12
+ def generate_cert():
13
+ # Generate private key
14
+ private_key = rsa.generate_private_key(
15
+ public_exponent=65537,
16
+ key_size=2048,
17
+ backend=default_backend()
18
+ )
19
+
20
+ # Generate a self-signed certificate
21
+ subject = issuer = x509.Name([
22
+ x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
23
+ x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "California"),
24
+ x509.NameAttribute(NameOID.LOCALITY_NAME, "San Francisco"),
25
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, "My Organization"),
26
+ x509.NameAttribute(NameOID.COMMON_NAME, "localhost"),
27
+ ])
28
+
29
+ certificate = (
30
+ x509.CertificateBuilder()
31
+ .subject_name(subject)
32
+ .issuer_name(issuer)
33
+ .public_key(private_key.public_key())
34
+ .serial_number(x509.random_serial_number())
35
+ .not_valid_before(datetime.utcnow())
36
+ .not_valid_after(datetime.utcnow() + timedelta(days=365))
37
+ .add_extension(
38
+ x509.SubjectAlternativeName([
39
+ x509.DNSName("localhost"),
40
+ IPAddress(ipaddress.IPv4Address("127.0.0.1"))
41
+ ]),
42
+ critical=False
43
+ )
44
+ .sign(private_key, hashes.SHA256(), default_backend())
45
+ )
46
+
47
+ cert_bytes = certificate.public_bytes(Encoding.PEM)
48
+ private_key_bytes = private_key.private_bytes(
49
+ encoding=Encoding.PEM,
50
+ format=PrivateFormat.TraditionalOpenSSL,
51
+ encryption_algorithm=NoEncryption()
52
+ )
53
+
54
+ return cert_bytes, private_key_bytes
@@ -0,0 +1,86 @@
1
+ import os
2
+ import logging
3
+ from typing import Callable, Tuple
4
+
5
+ from distributed_state_network.util.ecdsa import generate_key_pair
6
+ from distributed_state_network.util.https import generate_cert
7
+
8
+ class KeyManager:
9
+ def __init__(
10
+ self,
11
+ key_type: str,
12
+ node_id: str,
13
+ folder: str,
14
+ public_extension: str,
15
+ private_extension: str,
16
+ gen_keys: Callable[[], Tuple[bytes, bytes]]
17
+ ):
18
+ self.key_type = key_type
19
+ self.node_id = node_id
20
+ self.folder = folder
21
+ self.public_extension = public_extension
22
+ self.private_extension = private_extension
23
+ self.gen_keys = gen_keys
24
+
25
+ def write_public(self, node_id: str, cert: bytes):
26
+ if not os.path.exists(self.folder):
27
+ os.mkdir(self.folder)
28
+ if not os.path.exists(f'{self.folder}/{node_id}'):
29
+ os.mkdir(f'{self.folder}/{node_id}')
30
+ with open(f'{self.folder}/{self.node_id}/{node_id}.{self.public_extension}', 'wb') as f:
31
+ f.write(cert)
32
+
33
+ def read_public(self, node_id: str) -> bytes:
34
+ if not os.path.exists(f'{self.folder}/{self.node_id}/{node_id}.{self.public_extension}'):
35
+ raise Exception(401)
36
+ with open(f'{self.folder}/{self.node_id}/{node_id}.{self.public_extension}', 'rb') as f:
37
+ return f.read()
38
+
39
+ def has_public(self, node_id: str) -> bool:
40
+ return os.path.exists(f'{self.folder}/{self.node_id}/{node_id}.{self.public_extension}')
41
+
42
+ def ensure_public(self, node_id: str, public_key: bytes):
43
+ if self.has_public(node_id):
44
+ if not self.verify_public(node_id, public_key):
45
+ raise Exception(401)
46
+ else:
47
+ self.write_public(node_id, public_key)
48
+
49
+ def public_path(self, node_id: str):
50
+ return f"{self.folder}/{self.node_id}/{node_id}.{self.public_extension}"
51
+
52
+ def verify_public(self, node_id: str, public_key: bytes):
53
+ if not self.has_public(node_id):
54
+ return False
55
+ return public_key == self.read_public(node_id)
56
+
57
+ def my_public(self) -> bytes:
58
+ return self.read_public(self.node_id)
59
+
60
+ def my_private(self):
61
+ if not os.path.exists(f'{self.folder}/{self.node_id}/{self.node_id}.{self.private_extension}'):
62
+ raise Exception("Private key not found")
63
+ with open(f'{self.folder}/{self.node_id}/{self.node_id}.{self.private_extension}', 'rb') as f:
64
+ return f.read()
65
+
66
+ def generate_keys(self):
67
+ if os.path.exists(f'{self.folder}/{self.node_id}/{self.node_id}.{self.private_extension}'):
68
+ return
69
+ logging.getLogger("DSN: " + self.node_id).info(f"Generating {self.key_type} Keys ...")
70
+ cert_bytes, key_bytes = self.gen_keys()
71
+ if not os.path.exists(self.folder):
72
+ os.mkdir(self.folder)
73
+ if not os.path.exists(f'{self.folder}/{self.node_id}'):
74
+ os.mkdir(f'{self.folder}/{self.node_id}')
75
+ with open(f'{self.folder}/{self.node_id}/{self.node_id}.{self.public_extension}', 'wb') as f:
76
+ f.write(cert_bytes)
77
+ with open(f'{self.folder}/{self.node_id}/{self.node_id}.{self.private_extension}', 'wb') as f:
78
+ f.write(key_bytes)
79
+
80
+ class CertManager(KeyManager):
81
+ def __init__(self, node_id: str):
82
+ KeyManager.__init__(self, "HTTPS", node_id, "certs", "crt", "key", generate_cert)
83
+
84
+ class CredentialManager(KeyManager):
85
+ def __init__(self, node_id: str):
86
+ KeyManager.__init__(self, "ECDSA", node_id, "credentials", "pub", "key", generate_key_pair)
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: distributed-state-network
3
+ Version: 0.0.1
4
+ Summary: A tool to distribute the state of a network device to other devices on the network
5
+ Project-URL: Homepage, https://github.com/erinclemmer/distributed_state_network
6
+ Project-URL: Issues, https://github.com/erinclemmer/distributed_state_network/issues
7
+ Author-email: Erin Clemmer <erin.c.clemmer@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: distributed,networking
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Telecommunications Industry
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python
16
+ Classifier: Topic :: Communications
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: cryptography
20
+ Requires-Dist: ecdsa
21
+ Requires-Dist: ipaddress
22
+ Requires-Dist: logging
23
+ Requires-Dist: requests
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Distributed State Network
27
+
28
+ A Python framework for building distributed applications where nodes automatically share state without explicit data requests.
29
+
30
+ ## Why DSN?
31
+
32
+ Traditional distributed systems require constant polling or complex pub/sub mechanisms to share state between nodes. DSN solves this by providing:
33
+
34
+ - **Automatic state synchronization** - Changes propagate instantly across the network
35
+ - **No single point of failure** - Every node maintains its own state
36
+ - **Simple key-value interface** - Read any node's data as easily as local variables
37
+ - **Complete Security** - Triple-layer encryption protects your network
38
+
39
+ Perfect for building distributed monitoring systems, IoT networks, or any application where multiple machines need to share state efficiently.
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ pip install distributed-state-network
45
+ ```
46
+
47
+ ## Quick Start
48
+
49
+ ### 1. Create Your First Node
50
+
51
+ The simplest DSN network is a single node:
52
+
53
+ ```python
54
+ from distributed_state_network import DSNodeServer, DSNodeConfig
55
+
56
+ # Generate a network key (do this once for your entire network)
57
+ DSNodeServer.generate_key("network.key")
58
+
59
+ # Start a node
60
+ node = DSNodeServer.start(DSNodeConfig(
61
+ node_id="my_first_node",
62
+ port=8000,
63
+ aes_key_file="network.key",
64
+ bootstrap_nodes=[] # Empty for the first node
65
+ ))
66
+
67
+ # Write some data
68
+ node.node.update_data("status", "online")
69
+ node.node.update_data("temperature", "72.5")
70
+ ```
71
+
72
+ ## How It Works
73
+
74
+ DSN creates a peer-to-peer network where each node maintains its own state database:
75
+
76
+ **Key concepts:**
77
+ - Each node owns its state and is the only one who can modify it
78
+ - State changes are automatically broadcast to all connected nodes
79
+ - Any node can read any other node's state instantly
80
+ - All communication is encrypted with AES + ECDSA + HTTPS
81
+
82
+ ## API Reference
83
+
84
+ ### Node Methods
85
+
86
+ **update_data(key, value)** - Update a key in this node's state
87
+ ```python
88
+ node.update_data("sensor_reading", "42.0")
89
+ ```
90
+
91
+ **read_data(node_id, key)** - Read a value from any node's state
92
+
93
+ ```python
94
+ temperature = node.read_data("sensor_node", "temperature")
95
+ ```
96
+
97
+ **peers()** - List all connected nodes
98
+ ```python
99
+ connected_nodes = node.peers()
100
+ ```
101
+
102
+ ## Real-World Examples
103
+
104
+ ### Distributed Temperature Monitoring
105
+
106
+ Create a network of temperature sensors that share readings:
107
+
108
+ ```python
109
+ # On each Raspberry Pi with a sensor:
110
+ sensor_node = DSNodeServer.start(DSNodeConfig(
111
+ node_id=f"sensor_{location}",
112
+ port=8000,
113
+ aes_key_file="network.key",
114
+ bootstrap_nodes=[{"address": "coordinator.local", "port": 8000}]
115
+ ))
116
+
117
+ # Continuously update temperature
118
+ while True:
119
+ temp = read_temperature_sensor()
120
+ sensor_node.node.update_data("temperature", str(temp))
121
+ sensor_node.node.update_data("timestamp", str(time.time()))
122
+ time.sleep(60)
123
+ ```
124
+
125
+ On the monitoring station:
126
+ ```python
127
+ for node_id in monitor.node.peers():
128
+ if node_id.startswith("sensor_"):
129
+ temp = monitor.node.read_data(node_id, "temperature")
130
+ print(f"{node_id}: {temp}°F")
131
+ ```
132
+
133
+ ## Troubleshooting
134
+
135
+ ### Node Can't Connect
136
+ - Verify the AES key file is identical on all nodes
137
+ - Check firewall rules allow traffic on the configured port
138
+ - Ensure bootstrap node address is reachable
139
+
140
+ ### State Not Updating
141
+ - Confirm nodes show as connected with `node.peers()`
142
+ - Check network latency between nodes
143
+ - Verify no duplicate node IDs (each must be unique)
144
+
145
+ ## Performance Characteristics
146
+ - State updates typically propagate in <100ms on local networks
147
+ - Each node stores the complete state of all other nodes
148
+ - Suitable for networks up to ~100 nodes
149
+ - State values should be kept reasonably small (< 1MB per key)
@@ -0,0 +1,20 @@
1
+ distributed_state_network/__init__.py,sha256=vqniZquF5Z9P3extp_DyXyyskSg9k3tSLq2Huky7dmg,240
2
+ distributed_state_network/create_key.py,sha256=fZcAy0AgQza3R7HWLgyNHq_yRo28mLFvB-6jdNdm5jc,247
3
+ distributed_state_network/dsnode.py,sha256=n45PlVCxqyN-nD42GBt-dNr7il6CBZL5oTaZnjA7DrI,10756
4
+ distributed_state_network/handler.py,sha256=61gMWN3nHwtEghvn9XArzcmdduYPLOMjXQFtngMRcBI,3823
5
+ distributed_state_network/objects/config.py,sha256=p9sKKam2dcRiLuAgJ4KkGsXxbjec2EGQbqNHK3Cu548,485
6
+ distributed_state_network/objects/endpoint.py,sha256=odDAc9nYFtViVYF68zgmJIN1f9NF5bGAIo66YyvOzso,885
7
+ distributed_state_network/objects/hello_packet.py,sha256=oWUehRFRy6sYdxbKMaX_4YHaZFS7S8VxKm7V1f0BWJw,2033
8
+ distributed_state_network/objects/peers_packet.py,sha256=sNAJ32ViW5KD_45XW8hPi_KqU7lfPYwXU0DRgU8bFao,1414
9
+ distributed_state_network/objects/signed_packet.py,sha256=NnpfdJVk1fgeVSqnGYRzDMwYmTwQgqn3XDrq3k_0mwg,601
10
+ distributed_state_network/objects/state_packet.py,sha256=-tlhEh-0sNN1EGmbGxXwBrcmDCOmsLTFWkGVRuF-d-Y,1904
11
+ distributed_state_network/util/__init__.py,sha256=aV1nFoFsIxVfT8qXhYevJx9GQ7hth5bbpt59xT6qk2I,995
12
+ distributed_state_network/util/aes.py,sha256=CYMw2C4wyTdTyo3p2rq4Q2fLkvDobOLYBxW-_6dYSsQ,1572
13
+ distributed_state_network/util/byte_helper.py,sha256=dAhJW_gIXlJYoWnC9WSO3i8b0zgvofpKUa1vrr9IPhQ,976
14
+ distributed_state_network/util/ecdsa.py,sha256=-u9VtEEcx4TLNotIhM7m2uweXA92eznsW0EVFgY9s6M,804
15
+ distributed_state_network/util/https.py,sha256=k2eNOHQih_-6NPARpBnfpDCPDf1TFtXG3LiIgIMHsxU,1985
16
+ distributed_state_network/util/key_manager.py,sha256=5Ho10putWGglmeC24SEGqxAAaOfT0CMiCP99Toq0LfM,3616
17
+ distributed_state_network-0.0.1.dist-info/METADATA,sha256=dL_B9cNDiuO5JjVwH0KIZmg3efjzpdAzFQ_OBUUV__s,4577
18
+ distributed_state_network-0.0.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
19
+ distributed_state_network-0.0.1.dist-info/licenses/LICENSE,sha256=cfYX34vuMK24B499ZkFHRoVWarXM9-p0yyy0OrtSYdQ,1090
20
+ distributed_state_network-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Colton Clemmer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.