artystd 0.0.2__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.
- artystd-0.0.2/LICENSE +21 -0
- artystd-0.0.2/PKG-INFO +45 -0
- artystd-0.0.2/README.md +22 -0
- artystd-0.0.2/pyproject.toml +39 -0
- artystd-0.0.2/src/artystd/ashtp/ASHTPClient.py +533 -0
- artystd-0.0.2/src/artystd/ashtp/ASHTPServer.py +425 -0
- artystd-0.0.2/src/artystd/ashtp/IKeyAuthority.py +34 -0
- artystd-0.0.2/src/artystd/ashtp/StaticKeyPairGenerator.py +19 -0
- artystd-0.0.2/src/artystd/ashtp/__init__.py +5 -0
- artystd-0.0.2/src/artystd/utilities/ArtysEventManager.py +30 -0
- artystd-0.0.2/src/artystd/utilities/ArtysLogger.py +14 -0
- artystd-0.0.2/src/artystd/utilities/__init__.py +4 -0
- artystd-0.0.2/src/artystd/utilities/cap.py +66 -0
artystd-0.0.2/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arty
|
|
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.
|
artystd-0.0.2/PKG-INFO
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: artystd
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Arty's standard library
|
|
5
|
+
Project-URL: Homepage, https://github.com/Artytuihj/Artys_STD_LIB
|
|
6
|
+
Project-URL: Issues, https://github.com/Artytuihj/Artys_STD_LIB/issues/
|
|
7
|
+
Author: Artytuihj
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: stdlib
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Provides-Extra: all
|
|
15
|
+
Requires-Dist: artys-std[servernet]; extra == 'all'
|
|
16
|
+
Provides-Extra: clientnet
|
|
17
|
+
Requires-Dist: cryptography>=42; extra == 'clientnet'
|
|
18
|
+
Requires-Dist: requests>=2.31; extra == 'clientnet'
|
|
19
|
+
Provides-Extra: servernet
|
|
20
|
+
Requires-Dist: artys-std[clientnet]; extra == 'servernet'
|
|
21
|
+
Requires-Dist: fastapi>=0.110; extra == 'servernet'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Arty's STD LIB
|
|
25
|
+
|
|
26
|
+
A collection of commonly used modules shared across all of My projects.
|
|
27
|
+
|
|
28
|
+
## Modules
|
|
29
|
+
|
|
30
|
+
| Module | Full name | What it does |
|
|
31
|
+
|--------|-----------|--------------|
|
|
32
|
+
| **ASHTP** | Arty's Secure HTTP Transport Protocol | Custom HTTP wrapper for environments where TLS and HTTPS aren't available |
|
|
33
|
+
| **Events Manager** | Arty's Events Manager | Subscribe to and invoke events across threads and scripts |
|
|
34
|
+
| **CAP** | Command and Argument Parser | Define commands along with their arguments and flags |
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
### ASHTP
|
|
39
|
+
Custom HTTP wrapper for use when TLS and HTTPS aren't available.
|
|
40
|
+
|
|
41
|
+
### Events Manager
|
|
42
|
+
Lets events be subscribed to and invoked from different threads and scripts.
|
|
43
|
+
|
|
44
|
+
### CAP
|
|
45
|
+
Lets you define commands and their arguments and flags.
|
artystd-0.0.2/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Arty's STD LIB
|
|
2
|
+
|
|
3
|
+
A collection of commonly used modules shared across all of My projects.
|
|
4
|
+
|
|
5
|
+
## Modules
|
|
6
|
+
|
|
7
|
+
| Module | Full name | What it does |
|
|
8
|
+
|--------|-----------|--------------|
|
|
9
|
+
| **ASHTP** | Arty's Secure HTTP Transport Protocol | Custom HTTP wrapper for environments where TLS and HTTPS aren't available |
|
|
10
|
+
| **Events Manager** | Arty's Events Manager | Subscribe to and invoke events across threads and scripts |
|
|
11
|
+
| **CAP** | Command and Argument Parser | Define commands along with their arguments and flags |
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
### ASHTP
|
|
16
|
+
Custom HTTP wrapper for use when TLS and HTTPS aren't available.
|
|
17
|
+
|
|
18
|
+
### Events Manager
|
|
19
|
+
Lets events be subscribed to and invoked from different threads and scripts.
|
|
20
|
+
|
|
21
|
+
### CAP
|
|
22
|
+
Lets you define commands and their arguments and flags.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling >= 1.26"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "artystd"
|
|
7
|
+
version = "0.0.2"
|
|
8
|
+
authors = [{ name = "Artytuihj" }]
|
|
9
|
+
description = "Arty's standard library"
|
|
10
|
+
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
keywords = ["stdlib"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
]
|
|
17
|
+
license = "MIT"
|
|
18
|
+
license-files = ["LICEN[CS]E*"]
|
|
19
|
+
|
|
20
|
+
readme = "README.md"
|
|
21
|
+
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
clientnet = ["requests>=2.31", "cryptography>=42"]
|
|
26
|
+
servernet = ["artys-std[clientnet]", "fastapi>=0.110"]
|
|
27
|
+
all = ["artys-std[servernet]"]
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.sdist]
|
|
30
|
+
include = [
|
|
31
|
+
"/src"
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["/src/artystd"]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/Artytuihj/Artys_STD_LIB"
|
|
39
|
+
Issues = "https://github.com/Artytuihj/Artys_STD_LIB/issues/"
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
#std
|
|
2
|
+
import json
|
|
3
|
+
import secrets
|
|
4
|
+
import tempfile
|
|
5
|
+
import time
|
|
6
|
+
import zipfile
|
|
7
|
+
from email import generator
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
from typing import Iterable, Iterator
|
|
14
|
+
|
|
15
|
+
#pypi
|
|
16
|
+
import requests
|
|
17
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
|
|
18
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
19
|
+
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
|
20
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
21
|
+
from cryptography.exceptions import InvalidTag
|
|
22
|
+
from requests import Response
|
|
23
|
+
|
|
24
|
+
#in module
|
|
25
|
+
from src.artystd.ashtp import PROTOCOL_VERSION
|
|
26
|
+
from src.artystd.utilities.ArtysEventManager import ArtysEventManager
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HandshakeError(Exception):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
class ParsingError(Exception):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
class ReplayError(Exception):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
class ASHTPGeneralException(Exception):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _DeriveKeys(secret:bytes, transcrypt:bytes, length:int=64)-> tuple[bytes,bytes]:
|
|
43
|
+
symKey = HKDF(algorithm=hashes.SHA256(), length=length, salt=None,
|
|
44
|
+
info=b"ashtp sym sesscion key" + transcrypt).derive(secret)
|
|
45
|
+
return symKey[32:], symKey[:32]
|
|
46
|
+
|
|
47
|
+
def _aad(sid: bytes, method: str, endpoint: str, ref: int | None = None) -> bytes:
|
|
48
|
+
if not endpoint.startswith("/"):
|
|
49
|
+
endpoint = "/" + endpoint
|
|
50
|
+
a = sid + method.upper().encode() + b" " + endpoint.encode()
|
|
51
|
+
if ref is not None:
|
|
52
|
+
a += b" " + ref.to_bytes(8, "little") # fixed width at the end, so unambiguous
|
|
53
|
+
return a
|
|
54
|
+
|
|
55
|
+
class Session():
|
|
56
|
+
def __init__(self, send_key:bytes, recv_key:bytes):
|
|
57
|
+
self.send = ChaCha20Poly1305(send_key)
|
|
58
|
+
self.recv = ChaCha20Poly1305(recv_key)
|
|
59
|
+
self.sendN = 0
|
|
60
|
+
self.recvN = -1
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _nonce(n: int) -> bytes:
|
|
64
|
+
return b"\0" * 4 + n.to_bytes(8, "little") # generates a 12 byte number ( 0 0 0 0 )just filed with zeros (0 0 0 0 0 0 0 0) a simple counter little means less significant comes first
|
|
65
|
+
|
|
66
|
+
def Encrypt(self, data:bytes, tag:bytes = b""):
|
|
67
|
+
n = self.sendN # save the number used to build the nonce
|
|
68
|
+
self.sendN += 1 # increment
|
|
69
|
+
return n, self.send.encrypt(self._nonce(n), data, tag) # here we encrypt data with the send key and tag (ct stands for "cyphertext")
|
|
70
|
+
|
|
71
|
+
def Decrypt(self, data:bytes,n ,tag:bytes = b""):
|
|
72
|
+
if not ( self.recvN < n < 2**64): # make sure we are not being replayed and below the limit
|
|
73
|
+
raise ReplayError("The message is being replayed or key is stale. rekey! ")
|
|
74
|
+
dr = self.recv.decrypt(self._nonce(n), data, tag) # do the same as encrypt but backwards ("dr" stands for decryption result)
|
|
75
|
+
self.recvN = n
|
|
76
|
+
return dr
|
|
77
|
+
|
|
78
|
+
class ASHTPClient:
|
|
79
|
+
def __init__(self,
|
|
80
|
+
server_public_encryption_const: bytes,
|
|
81
|
+
server_ip: str,
|
|
82
|
+
server_port: int | None = None,
|
|
83
|
+
url_prefix: str = "http://",
|
|
84
|
+
url_health: str = "/health",
|
|
85
|
+
url_handshake: str = "/privet",
|
|
86
|
+
url_auth: str = "/auth",
|
|
87
|
+
timeout_time: int = 5,
|
|
88
|
+
max_reties: int = 5,
|
|
89
|
+
health_poll_rate: int = 5):
|
|
90
|
+
|
|
91
|
+
# ----------------------------------------------
|
|
92
|
+
# ||| Arty's Secure HTTP Transport Protocol |||
|
|
93
|
+
# ----------------------------------------------
|
|
94
|
+
|
|
95
|
+
# ---------- protocol ----------
|
|
96
|
+
self._version: str = PROTOCOL_VERSION
|
|
97
|
+
|
|
98
|
+
# ---------- server address ----------
|
|
99
|
+
self.serverIp: str = server_ip
|
|
100
|
+
self.serverPort: int | None = server_port
|
|
101
|
+
self.serverPrefix: str = url_prefix
|
|
102
|
+
self.serverHealthEndpoint: str = url_health
|
|
103
|
+
self.serverHandshakeEndpoint: str = url_handshake
|
|
104
|
+
self.serverAuthEndpoint: str = url_auth
|
|
105
|
+
self.serverUrl: str = f"{self.serverPrefix}{self.serverIp}{f":{self.serverPort}" if self.serverPort else ""}"
|
|
106
|
+
|
|
107
|
+
# ---------- request behaviour ----------
|
|
108
|
+
self.timeoutTime: int = timeout_time
|
|
109
|
+
self.maxRequestRetries: int = max_reties
|
|
110
|
+
|
|
111
|
+
# ---------- health polling settings ----------
|
|
112
|
+
self.healthCheckRate: int = health_poll_rate
|
|
113
|
+
|
|
114
|
+
# ---------- file transfer ----------
|
|
115
|
+
self._tmp = Path(tempfile.gettempdir()) / "ashtp" / "C"
|
|
116
|
+
self._tmp.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
|
|
118
|
+
# ---------- integrations (optional) ----------
|
|
119
|
+
self.logger = logging.getLogger(__name__)
|
|
120
|
+
|
|
121
|
+
# ---------- crypto / handshake ----------
|
|
122
|
+
self.Sb: bytes = server_public_encryption_const # server's constant public key
|
|
123
|
+
self.HELLO_REPLY_FORMAT = {
|
|
124
|
+
"F": {"type": bytes, "len": 32, "required": True}, # servers temp public key to make the symertric one
|
|
125
|
+
"session_id": {"type": bytes, "len": 16, "required": True}, # just a random id
|
|
126
|
+
"ok": {"type": bytes, "len": 23, "required": True}, # ts sends "ok" and a 16 bit tag to detect tampering or corruption
|
|
127
|
+
}
|
|
128
|
+
self.AUTH_REPLY_FORMAT = {
|
|
129
|
+
"ok": {"type": str, "required": True}
|
|
130
|
+
}
|
|
131
|
+
self.secureTunnelActive = False
|
|
132
|
+
self.session:Session = None
|
|
133
|
+
self.sessionId:bytes = b""
|
|
134
|
+
self.apiKey: str = ""
|
|
135
|
+
self._connecting: bool = False
|
|
136
|
+
self._lastConnect: float = 0.0
|
|
137
|
+
|
|
138
|
+
# ---------- runtime state ----------
|
|
139
|
+
self.isOnline: bool = False
|
|
140
|
+
self.failedRequests: int = 0
|
|
141
|
+
|
|
142
|
+
# ---------- background tasks (keep last, they use everything above) ----------
|
|
143
|
+
self._healthTask = None
|
|
144
|
+
|
|
145
|
+
def Log(self, message:str):
|
|
146
|
+
self.logger.info(message)
|
|
147
|
+
|
|
148
|
+
def Error(self, message:str):
|
|
149
|
+
self.logger.error(message)
|
|
150
|
+
|
|
151
|
+
def emit(self, event:str ,*args, **kwargs):
|
|
152
|
+
ArtysEventManager.EmitEvent(event, *args, **kwargs)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def Parse(self, msg: dict, fmt: dict)-> dict:
|
|
156
|
+
"""
|
|
157
|
+
takes one dict and compares it to a template
|
|
158
|
+
:param msg:
|
|
159
|
+
:param fmt:
|
|
160
|
+
:return:
|
|
161
|
+
"""
|
|
162
|
+
if not isinstance(msg, dict) or set(msg) - set(fmt):
|
|
163
|
+
raise ParsingError("msg inst a dict or has forbidden fields")
|
|
164
|
+
output = {}
|
|
165
|
+
|
|
166
|
+
for name , spec in fmt.items():
|
|
167
|
+
if name not in msg:
|
|
168
|
+
if spec["required"]:
|
|
169
|
+
raise ParsingError("msg is missing keys defined in template")
|
|
170
|
+
continue
|
|
171
|
+
value = msg[name]
|
|
172
|
+
if spec["type"] is bytes:
|
|
173
|
+
try:
|
|
174
|
+
value = base64.b64decode(value, validate=True)
|
|
175
|
+
except Exception as e:
|
|
176
|
+
raise ParsingError(f"{name} isn't bytes or the field is corrupted")
|
|
177
|
+
|
|
178
|
+
if len(value) != spec["len"]:
|
|
179
|
+
raise ParsingError(f"{name} dosen't match length target")
|
|
180
|
+
elif not isinstance(value, spec["type"]):
|
|
181
|
+
raise ParsingError(f"{name} dosen't match type")
|
|
182
|
+
output[name] = value
|
|
183
|
+
return output
|
|
184
|
+
|
|
185
|
+
def Seal(self, msg:bytes, method:str, path:str,session:Session|None = None, sessionId:bytes|None = None, ref:int|None = None) -> tuple[bytes, int]:
|
|
186
|
+
""" Turns unencrypted JSON into request ready data
|
|
187
|
+
|
|
188
|
+
:param ref:
|
|
189
|
+
:param sessionId:
|
|
190
|
+
:param session:
|
|
191
|
+
:param msg:
|
|
192
|
+
:param method:
|
|
193
|
+
:param path:
|
|
194
|
+
:return:
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
sec = session if session is not None else self.session
|
|
198
|
+
secId = sessionId if sessionId is not None else self.sessionId
|
|
199
|
+
|
|
200
|
+
plain = json.dumps(msg).encode()
|
|
201
|
+
tag = _aad(secId, method, path, ref=ref)
|
|
202
|
+
n, data = sec.Encrypt(plain, tag)
|
|
203
|
+
|
|
204
|
+
return data, n
|
|
205
|
+
|
|
206
|
+
def SealFileStream(self, meta:dict, filePath:Path, method:str, path:str, chunkSize:int=64*1024,
|
|
207
|
+
session:Session|None = None, sessionId:bytes|None = None, ref:int|None = None) -> Iterator[tuple[bytes, int]]:
|
|
208
|
+
""" Turns an unencrypted File into request ready byte stream
|
|
209
|
+
|
|
210
|
+
:param chunkSize:
|
|
211
|
+
:param filePath:
|
|
212
|
+
:param meta:
|
|
213
|
+
:param method:
|
|
214
|
+
:param path:
|
|
215
|
+
:param sessionId:
|
|
216
|
+
:param session:
|
|
217
|
+
:param ref:
|
|
218
|
+
:return:
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
sec = session if session is not None else self.session
|
|
222
|
+
secId = sessionId if sessionId is not None else self.sessionId
|
|
223
|
+
initialRequest = json.dumps( meta | {"chunk_size": chunkSize} ).encode()
|
|
224
|
+
with tempfile.TemporaryDirectory(dir=self._tmp) as td: # niche thing i like
|
|
225
|
+
|
|
226
|
+
zipPath = Path(td) / f"{filePath.name}.zip"
|
|
227
|
+
with zipfile.ZipFile(zipPath, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
228
|
+
for file in filePath.rglob("*"):
|
|
229
|
+
if file.is_dir():
|
|
230
|
+
zf.write(file, arcname=str(file.relative_to(filePath)) + "/")
|
|
231
|
+
else:
|
|
232
|
+
zf.write(file, arcname=file.relative_to(filePath))
|
|
233
|
+
|
|
234
|
+
data, n = self.Seal(initialRequest, method, path, sec, secId, ref)
|
|
235
|
+
yield data, n
|
|
236
|
+
|
|
237
|
+
with zipPath.open("rb") as f:
|
|
238
|
+
while chunk := f.read(chunkSize):
|
|
239
|
+
data, n = self.Seal(chunk, method, path, sec, secId, ref)
|
|
240
|
+
yield data, n
|
|
241
|
+
|
|
242
|
+
def TearOpen(self, msg:bytes, method:str, path:str, serverCount:int, session:Session|None = None, sessionId:bytes|None = None, ref:int|None = None) -> dict:
|
|
243
|
+
"""
|
|
244
|
+
Turns encrypt byte data into (hopefuly) a readable JSON
|
|
245
|
+
|
|
246
|
+
:param ref:
|
|
247
|
+
:param serverCount:
|
|
248
|
+
:param msg:
|
|
249
|
+
:param method:
|
|
250
|
+
:param path:
|
|
251
|
+
:param session:
|
|
252
|
+
:param sessionId:
|
|
253
|
+
:return:
|
|
254
|
+
"""
|
|
255
|
+
sec = session if session is not None else self.session
|
|
256
|
+
secId = sessionId if sessionId is not None else self.sessionId
|
|
257
|
+
|
|
258
|
+
if sec is None or secId is None:
|
|
259
|
+
raise HandshakeError("no active session")
|
|
260
|
+
|
|
261
|
+
path = path if path.startswith("/") else "/" + path
|
|
262
|
+
n = serverCount
|
|
263
|
+
tag = _aad(secId, method, path, ref=ref)
|
|
264
|
+
plain = sec.Decrypt(msg, n, tag)
|
|
265
|
+
|
|
266
|
+
return json.loads(plain.decode())
|
|
267
|
+
|
|
268
|
+
async def Init(self,api_key:str) -> bool:
|
|
269
|
+
"""
|
|
270
|
+
Initializes the connection with the server and creates the encrypted authenticated channel.
|
|
271
|
+
This function HAS to be used at the start of comunication with any server running the "ashtp" protocol.
|
|
272
|
+
:param api_key:
|
|
273
|
+
:return:
|
|
274
|
+
"""
|
|
275
|
+
self.apiKey = api_key
|
|
276
|
+
key_id = hashlib.blake2b(self.Sb, digest_size=8).digest()
|
|
277
|
+
proto = self._version.encode("utf-8")
|
|
278
|
+
|
|
279
|
+
for i in range(3):
|
|
280
|
+
|
|
281
|
+
e = X25519PrivateKey.generate() # clients private key
|
|
282
|
+
E = e.public_key() # clients public key
|
|
283
|
+
Eb = E.public_bytes( # clients public key but converted to bytes
|
|
284
|
+
encoding=serialization.Encoding.Raw,
|
|
285
|
+
format=serialization.PublicFormat.Raw
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
eS = e.exchange(X25519PublicKey.from_public_bytes(self.Sb)) # mix our private with servers const public
|
|
289
|
+
|
|
290
|
+
#handshake package
|
|
291
|
+
hello = {
|
|
292
|
+
"protocol": self._version,
|
|
293
|
+
"spk_hash": base64.b64encode( key_id ).decode(),
|
|
294
|
+
"E": base64.b64encode( Eb ).decode(),
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#send of the handshake
|
|
298
|
+
try:
|
|
299
|
+
resp = await asyncio.to_thread(requests.post, f"{self.serverUrl}{self.serverHandshakeEndpoint}",
|
|
300
|
+
json = hello, timeout = self.timeoutTime)
|
|
301
|
+
resp.raise_for_status() # neat thing it raises when bad status arrives like 500 or 404
|
|
302
|
+
except Exception as err:
|
|
303
|
+
self.failedRequests += 1
|
|
304
|
+
self.Error(str(err))
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
reply = self.Parse(resp.json(), self.HELLO_REPLY_FORMAT)# check that the handshake package is actualy valid
|
|
309
|
+
F = reply["F"]
|
|
310
|
+
eF = e.exchange(X25519PublicKey.from_public_bytes(F)) # exchange keys to get the secret number (mix our private with servers public)
|
|
311
|
+
|
|
312
|
+
c2s, s2c = _DeriveKeys(eS + eF, proto + key_id + Eb + F + self.Sb + reply["session_id"])
|
|
313
|
+
session = Session(c2s, s2c)
|
|
314
|
+
|
|
315
|
+
if session.Decrypt(reply["ok"],0, b"") != b"lets go":
|
|
316
|
+
raise HandshakeError("<ok> didnt match the expected! keys/ package are either malformed or where tampered with")
|
|
317
|
+
|
|
318
|
+
except (HandshakeError, ParsingError, InvalidTag, ValueError) as err:
|
|
319
|
+
self.Error(f"Handshake Failed: {err}")
|
|
320
|
+
return False
|
|
321
|
+
try:
|
|
322
|
+
authPackage = json.dumps({ "api_key":api_key }).encode()
|
|
323
|
+
|
|
324
|
+
authBytes, n = self.Seal(authPackage, "POST", self.serverAuthEndpoint, session, reply["session_id"])
|
|
325
|
+
headers = {
|
|
326
|
+
"ashtp-Session": base64.b64encode(reply["session_id"]).decode(), # take session id make it into base64 and turn it into words
|
|
327
|
+
"ashtp-Counter": str(n), # the counter needed for encryption/decryption
|
|
328
|
+
"ashtp-Content": "json", # content type
|
|
329
|
+
}
|
|
330
|
+
resp = await asyncio.to_thread(requests.post, f"{self.serverUrl}{self.serverAuthEndpoint}",
|
|
331
|
+
data=authBytes, headers=headers, timeout=self.timeoutTime)
|
|
332
|
+
resp.raise_for_status()
|
|
333
|
+
serverCounter= int(resp.headers["ashtp-Counter"])
|
|
334
|
+
resp = self.TearOpen(resp.content, "POST", self.serverAuthEndpoint, serverCounter ,session, reply["session_id"], ref=n)
|
|
335
|
+
|
|
336
|
+
resp = self.Parse(resp, self.AUTH_REPLY_FORMAT)
|
|
337
|
+
if not resp.get("ok") == "ok": raise HandshakeError("couldnt log in")
|
|
338
|
+
|
|
339
|
+
except Exception as err:
|
|
340
|
+
self.Error(f"Handshake Failed: {err}")
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
#lets go we connected
|
|
345
|
+
|
|
346
|
+
self.session = session
|
|
347
|
+
self.sessionId = reply["session_id"]
|
|
348
|
+
self.secureTunnelActive = True
|
|
349
|
+
self.isOnline = True
|
|
350
|
+
self.failedRequests = 0
|
|
351
|
+
self.emit("ashtp_server_status_changed", True)
|
|
352
|
+
return True
|
|
353
|
+
return False
|
|
354
|
+
|
|
355
|
+
async def CheckHealth(self):
|
|
356
|
+
combinedUrl = f"{self.serverUrl}{self.serverHealthEndpoint}"
|
|
357
|
+
|
|
358
|
+
while True:
|
|
359
|
+
try:
|
|
360
|
+
resp = await asyncio.to_thread(requests.get, combinedUrl,timeout=self.timeoutTime)
|
|
361
|
+
resp.raise_for_status()
|
|
362
|
+
if resp.json():
|
|
363
|
+
self.failedRequests = 0
|
|
364
|
+
else:
|
|
365
|
+
self.failedRequests += 1
|
|
366
|
+
|
|
367
|
+
except Exception as e:
|
|
368
|
+
print(e)
|
|
369
|
+
self.failedRequests += 1
|
|
370
|
+
|
|
371
|
+
prev = self.isOnline
|
|
372
|
+
if self.failedRequests > self.maxRequestRetries:
|
|
373
|
+
self.isOnline = False
|
|
374
|
+
self.secureTunnelActive = False
|
|
375
|
+
elif self.failedRequests == 0:
|
|
376
|
+
self.isOnline = True
|
|
377
|
+
if self.isOnline != prev:
|
|
378
|
+
self.Log(f"status -> {self.isOnline}")
|
|
379
|
+
self.emit("ashtp_server_status_changed", self.isOnline)
|
|
380
|
+
|
|
381
|
+
# rebuild the tunnel whenever the server is up but we have no working session
|
|
382
|
+
if (self.isOnline and not self.secureTunnelActive and self.apiKey
|
|
383
|
+
and not self._connecting and time.monotonic() - self._lastConnect > 15):
|
|
384
|
+
self._connecting = True
|
|
385
|
+
self._lastConnect = time.monotonic()
|
|
386
|
+
try:
|
|
387
|
+
await self.Init(self.apiKey)
|
|
388
|
+
except Exception as err:
|
|
389
|
+
self.Error(f"reconnect failed: {err}")
|
|
390
|
+
finally:
|
|
391
|
+
self._connecting = False
|
|
392
|
+
|
|
393
|
+
await asyncio.sleep(self.healthCheckRate)
|
|
394
|
+
|
|
395
|
+
async def _request(self, method: str, path: str, contentType:str, request: Iterable[bytes], timeout, usrHeaders:dict|None = None, sendBody: bool = True) -> dict:
|
|
396
|
+
if not self.isOnline:
|
|
397
|
+
raise ASHTPGeneralException("Server is offline")
|
|
398
|
+
if not self.secureTunnelActive:
|
|
399
|
+
raise ASHTPGeneralException("No active tunnel")
|
|
400
|
+
|
|
401
|
+
timeout = self.timeoutTime if timeout is None else timeout
|
|
402
|
+
path = path if path.startswith("/") else "/" + path
|
|
403
|
+
url = f"{self.serverUrl}{path}"
|
|
404
|
+
|
|
405
|
+
response:Response = Response()
|
|
406
|
+
|
|
407
|
+
if contentType == "json":
|
|
408
|
+
payload = next(iter(request)) if request is not None else json.dumps({"placeholder": secrets.randbits(64)}).encode()
|
|
409
|
+
last_error = ""
|
|
410
|
+
for attempt in range(self.maxRequestRetries):
|
|
411
|
+
data, n = self.Seal(payload, method, path)
|
|
412
|
+
|
|
413
|
+
headers = {
|
|
414
|
+
"ashtp-Session": base64.b64encode(self.sessionId).decode(), # take session id make it into base64 and turn it into words
|
|
415
|
+
"ashtp-Counter": str(n), # the counter needed for encryption/decryption
|
|
416
|
+
"ashtp-Content": "json", # content type
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
kwargs = {"headers": (usrHeaders or {}) | headers, "timeout": timeout}
|
|
420
|
+
if sendBody:
|
|
421
|
+
kwargs["data"] = data
|
|
422
|
+
if usrHeaders is not None:
|
|
423
|
+
body = kwargs.setdefault("data", {})
|
|
424
|
+
if not isinstance(body, dict):
|
|
425
|
+
raise TypeError("user headers can only be attached to dict bodies")
|
|
426
|
+
uh = body.setdefault("user_headers", {})
|
|
427
|
+
for key, header in usrHeaders.items():
|
|
428
|
+
if isinstance(header, str):
|
|
429
|
+
header = header.encode()
|
|
430
|
+
uh[key] = base64.b64encode(header).decode()
|
|
431
|
+
try:
|
|
432
|
+
resp = await asyncio.to_thread(requests.request, method, url, **kwargs)
|
|
433
|
+
resp.raise_for_status()
|
|
434
|
+
except requests.exceptions.HTTPError as e:
|
|
435
|
+
self.failedRequests += 1
|
|
436
|
+
last_error = str(e)
|
|
437
|
+
if e.response is not None and e.response.status_code == 401:
|
|
438
|
+
self.secureTunnelActive = False
|
|
439
|
+
if e.response is not None and e.response.status_code < 500:
|
|
440
|
+
raise ASHTPGeneralException(last_error) from e
|
|
441
|
+
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
|
442
|
+
self.failedRequests += 1
|
|
443
|
+
last_error = str(e)
|
|
444
|
+
else:
|
|
445
|
+
response = resp
|
|
446
|
+
|
|
447
|
+
await asyncio.sleep(
|
|
448
|
+
min(0.25 * 2 ** attempt, 5)) # wait longer each time we fail to not hammer the server
|
|
449
|
+
raise ASHTPGeneralException(last_error)
|
|
450
|
+
elif contentType == "file":
|
|
451
|
+
payload = request
|
|
452
|
+
|
|
453
|
+
if (contentType := response.headers.get("ashtp-Content")) is not None:
|
|
454
|
+
if type == "json":
|
|
455
|
+
...
|
|
456
|
+
elif type == "file":
|
|
457
|
+
...
|
|
458
|
+
raise ASHTPGeneralException("welp...")
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
async def GetData(self, path:str, request:dict=None, timeout:float=None, usrHeaders:dict=None) -> dict:
|
|
462
|
+
return await self._request("GET", path, request, timeout, usrHeaders, sendBody=True)
|
|
463
|
+
|
|
464
|
+
async def PostData(self, path:str, request:dict, timeout:float=None, usrHeaders:dict=None) -> dict:
|
|
465
|
+
return await self._request("POST", path, request, timeout, usrHeaders)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
async def GetFile(self, path:str, request:dict=None, timeout:float=None, usrHeaders:dict = None) -> Path:
|
|
469
|
+
"""
|
|
470
|
+
Streams a file from the server.
|
|
471
|
+
Can only be used to request files!
|
|
472
|
+
If the file are larger than 1GB they will be put in tmp.
|
|
473
|
+
|
|
474
|
+
:param path:
|
|
475
|
+
:param usrHeaders:
|
|
476
|
+
:param timeout:
|
|
477
|
+
:param request:
|
|
478
|
+
:return: path to downlaoded file
|
|
479
|
+
"""
|
|
480
|
+
|
|
481
|
+
if not self.isOnline:
|
|
482
|
+
raise ASHTPGeneralException("Server is offline")
|
|
483
|
+
if not self.secureTunnelActive:
|
|
484
|
+
raise ASHTPGeneralException("No active tunnel")
|
|
485
|
+
|
|
486
|
+
timeout = self.timeoutTime if timeout is None else timeout
|
|
487
|
+
path = path if path.startswith("/") else "/" + path
|
|
488
|
+
url = f"{self.serverUrl}{path}"
|
|
489
|
+
payload = request if request is not None else {"placeholder": secrets.randbits(64)}
|
|
490
|
+
|
|
491
|
+
data, headers, n = self.Seal(payload, "GET", url)
|
|
492
|
+
kwargs = {"headers": (usrHeaders or {}) | headers, "timeout": timeout}
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
async def PostFile(self, endPoint: str, file:Path,headers:dict = {}) -> dict:
|
|
499
|
+
"""
|
|
500
|
+
Posts file to the server.
|
|
501
|
+
Can only be used to upload files
|
|
502
|
+
|
|
503
|
+
:param endPoint:
|
|
504
|
+
:param file:
|
|
505
|
+
:return: response
|
|
506
|
+
"""
|
|
507
|
+
|
|
508
|
+
if not self.isOnline:
|
|
509
|
+
return {"status": "failed", "code": "400", "message": "server is offline"}
|
|
510
|
+
|
|
511
|
+
if not self.isOnline:
|
|
512
|
+
return {"status": "failed", "code": "400", "message": "a secure connection to the server was not established"}
|
|
513
|
+
|
|
514
|
+
combinedUrl = f"{self.serverUrl}{endPoint if endPoint.startswith("/") else "/" + endPoint}"
|
|
515
|
+
kwargs = {"headers": self.persistentHeaders | headers}
|
|
516
|
+
last_error = ""
|
|
517
|
+
|
|
518
|
+
with open(file, "rb") as f:
|
|
519
|
+
kwargs["headers"]["Content-Name"] = f.name
|
|
520
|
+
kwargs["files"] = {"file": f}
|
|
521
|
+
|
|
522
|
+
for i in range(0, self.maxRequestRetries):
|
|
523
|
+
f.seek(0)
|
|
524
|
+
try:
|
|
525
|
+
resp = await asyncio.to_thread(requests.post, combinedUrl, **kwargs)
|
|
526
|
+
resp.raise_for_status()
|
|
527
|
+
response = resp.json()
|
|
528
|
+
self.failedRequests = 0
|
|
529
|
+
return response
|
|
530
|
+
except Exception as e:
|
|
531
|
+
last_error = e
|
|
532
|
+
|
|
533
|
+
return {"status": "failed", "code": "000", "message": str(last_error)}
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import tempfile
|
|
6
|
+
import time
|
|
7
|
+
from collections import deque
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from cryptography.exceptions import InvalidTag
|
|
11
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
|
|
12
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
13
|
+
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
|
14
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
15
|
+
import base64
|
|
16
|
+
import hashlib
|
|
17
|
+
from fastapi import FastAPI, Request, Response
|
|
18
|
+
import logging
|
|
19
|
+
import inspect
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
import secrets
|
|
23
|
+
|
|
24
|
+
from src.artystd.ashtp import PROTOCOL_VERSION, SUPPORTED_SERVER_VERSIONS
|
|
25
|
+
from src.artystd.ashtp.IKeyAuthority import IKeyAuthority as ika
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HandshakeError(Exception):
|
|
29
|
+
pass
|
|
30
|
+
class ParsingError(Exception):
|
|
31
|
+
pass
|
|
32
|
+
class ReplayError(Exception):
|
|
33
|
+
pass
|
|
34
|
+
class BodyTooLarge(Exception):
|
|
35
|
+
pass
|
|
36
|
+
class ErrorHandlingError(Exception):
|
|
37
|
+
pass
|
|
38
|
+
class ClientTooOldError(Exception):
|
|
39
|
+
...
|
|
40
|
+
class KeyLacksPermissionError(Exception):
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
def _DeriveKeys(secret:bytes, transcrypt:bytes, length:int=64)-> tuple[bytes,bytes]:
|
|
44
|
+
symKey = HKDF(algorithm=hashes.SHA256(), length=length, salt=None,
|
|
45
|
+
info=b"ashtp sym sesscion key" + transcrypt).derive(secret)
|
|
46
|
+
return symKey[32:], symKey[:32]
|
|
47
|
+
|
|
48
|
+
def _aad(sid: bytes, method: str, endpoint: str, ref: int | None = None) -> bytes:
|
|
49
|
+
if not endpoint.startswith("/"):
|
|
50
|
+
endpoint = "/" + endpoint
|
|
51
|
+
a = sid + method.upper().encode() + b" " + endpoint.encode()
|
|
52
|
+
if ref is not None:
|
|
53
|
+
a += b" " + ref.to_bytes(8, "little") # fixed width at the end, so unambiguous
|
|
54
|
+
return a
|
|
55
|
+
|
|
56
|
+
async def _ReadBody(req: Request, limit: int) -> tuple[bytes, dict]:
|
|
57
|
+
cl = req.headers.get("content-length")
|
|
58
|
+
if cl is not None and int(cl) > limit:
|
|
59
|
+
raise BodyTooLarge
|
|
60
|
+
buf = bytearray()
|
|
61
|
+
async for chunk in req.stream():
|
|
62
|
+
buf += chunk
|
|
63
|
+
if len(buf) > limit:
|
|
64
|
+
raise BodyTooLarge
|
|
65
|
+
return bytes(buf), dict(req.headers)
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class ActiveSession:
|
|
69
|
+
session: Session
|
|
70
|
+
created: float = field(default_factory=time.monotonic)
|
|
71
|
+
permsLevel: int = -1
|
|
72
|
+
name: str = "None"
|
|
73
|
+
class Session:
|
|
74
|
+
def __init__(self, send_key:bytes, recv_key:bytes):
|
|
75
|
+
self.send = ChaCha20Poly1305(send_key)
|
|
76
|
+
self.recv = ChaCha20Poly1305(recv_key)
|
|
77
|
+
self.sendN = 0
|
|
78
|
+
self.recvN = -1
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _nonce(n: int) -> bytes:
|
|
82
|
+
return b"\0" * 4 + n.to_bytes(8, "little") # generates a 12 byte number ( 0 0 0 0 )just filed with zeros (0 0 0 0 0 0 0 0) a simple counter little means less significant comes first
|
|
83
|
+
|
|
84
|
+
def Encrypt(self, data:bytes, tag:bytes = b""):
|
|
85
|
+
n = self.sendN # save the number used to build the nonce
|
|
86
|
+
self.sendN += 1 # increment
|
|
87
|
+
return n, self.send.encrypt(self._nonce(n), data, tag) # here we encrypt data with the send key and tag (ct stands for "cyphertext")
|
|
88
|
+
|
|
89
|
+
def Decrypt(self, data:bytes,n ,tag:bytes = b""):
|
|
90
|
+
if not ( self.recvN < n < 2**64): # make sure we are not being replayed and below the limit
|
|
91
|
+
raise ReplayError("The message is being replayed or key is stale. rekey! ")
|
|
92
|
+
dr = self.recv.decrypt(self._nonce(n), data, tag) # do the same as encrypt but backwards ("dr" stands for decryption result)
|
|
93
|
+
self.recvN = n
|
|
94
|
+
return dr
|
|
95
|
+
class RateLimiter:#neat thingy
|
|
96
|
+
def __init__(self, limit: int, window: float):
|
|
97
|
+
self.limit = limit
|
|
98
|
+
self.window = window
|
|
99
|
+
self.hits: dict[str, deque[float]] = {} # store key and a queue of req attempts
|
|
100
|
+
|
|
101
|
+
def _Trim(self, key: str) -> deque:
|
|
102
|
+
q = self.hits.setdefault(key, deque()) # get the queue from the dict and if dosent exist yet make it
|
|
103
|
+
now = time.monotonic()
|
|
104
|
+
while q and now - q[0] > self.window: # repeat while q is a thing and q[0] fits within the window
|
|
105
|
+
q.popleft()
|
|
106
|
+
return q
|
|
107
|
+
|
|
108
|
+
def Count(self, key: str) -> int:#remove stale requests
|
|
109
|
+
return len(self._Trim(key))
|
|
110
|
+
|
|
111
|
+
def Blocked(self, key: str) -> bool:
|
|
112
|
+
return self.Count(key) >= self.limit# call count and check if we are over the limit
|
|
113
|
+
|
|
114
|
+
def Hit(self, key: str):
|
|
115
|
+
self._Trim(key).append(time.monotonic())# add to the counter
|
|
116
|
+
|
|
117
|
+
class ASHTPServer:
|
|
118
|
+
def __init__(self,
|
|
119
|
+
application:type[FastAPI],
|
|
120
|
+
server_private_encryption_const: bytes,
|
|
121
|
+
key_authority: ika,
|
|
122
|
+
url_prefix: str = "http://",
|
|
123
|
+
url_health: str = "/health",
|
|
124
|
+
url_handshake: str = "/privet",
|
|
125
|
+
url_auth: str = "/auth",):
|
|
126
|
+
|
|
127
|
+
# ----------------------------------------------
|
|
128
|
+
# ||| Arty's Secure HTTP Transport Protocol |||
|
|
129
|
+
# ----------------------------------------------
|
|
130
|
+
|
|
131
|
+
# --------- protocol ----------
|
|
132
|
+
self._version: str = PROTOCOL_VERSION
|
|
133
|
+
|
|
134
|
+
# ------- server address --------
|
|
135
|
+
self.serverPrefix: str = url_prefix
|
|
136
|
+
self.serverHealthEndpoint: str = url_health
|
|
137
|
+
self.serverHandshakeEndpoint: str = url_handshake
|
|
138
|
+
self.serverAuthEndpoint: str = url_auth
|
|
139
|
+
|
|
140
|
+
# -------- file transfer ---------
|
|
141
|
+
self._tmp = Path(tempfile.gettempdir()) / "ashtp" / "S"
|
|
142
|
+
self._tmp.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
|
|
144
|
+
# ---------- crypto / handshake -------
|
|
145
|
+
self.sb: bytes = server_private_encryption_const # server's constant private key
|
|
146
|
+
self.s = X25519PrivateKey.from_private_bytes(self.sb) # servers const private
|
|
147
|
+
self.S = self.s.public_key() # servers const public
|
|
148
|
+
self.Sb = self.S.public_bytes( # const public in bytes
|
|
149
|
+
encoding=serialization.Encoding.Raw,
|
|
150
|
+
format=serialization.PublicFormat.Raw
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
self.HELLO_FORMAT = {
|
|
154
|
+
"protocol": {"type": str, "required": True},
|
|
155
|
+
"spk_hash": {"type": bytes, "len": 8, "required": True},
|
|
156
|
+
"E": {"type": bytes, "len": 32, "required": True},
|
|
157
|
+
}
|
|
158
|
+
self.AUTH_FORMAT = {
|
|
159
|
+
"api_key": {"type": str, "required": True}
|
|
160
|
+
}
|
|
161
|
+
# ------- session management ---------
|
|
162
|
+
self.activeSessions: dict[bytes,ActiveSession] = {}
|
|
163
|
+
self.keyAuthority = key_authority
|
|
164
|
+
|
|
165
|
+
# --------- rate limits --------
|
|
166
|
+
self.hsLimit = RateLimiter(limit=300, window=60)
|
|
167
|
+
self.authFails = RateLimiter(limit=50, window=300)
|
|
168
|
+
self.maxSessions = 200
|
|
169
|
+
|
|
170
|
+
# -------- other ---------
|
|
171
|
+
self.logger = logging.getLogger(__name__)
|
|
172
|
+
|
|
173
|
+
# ------- app --------
|
|
174
|
+
self.app = application
|
|
175
|
+
self.app.add_api_route("/health", self.Health, methods=["GET"])
|
|
176
|
+
self.app.add_api_route("/privet", self.Handshake, methods=["POST"])
|
|
177
|
+
self.app.add_api_route("/auth", self.Authenticate, methods=["POST"])
|
|
178
|
+
|
|
179
|
+
# --------- tool chain --------
|
|
180
|
+
def Log(self, message:str):
|
|
181
|
+
self.logger.info(message)
|
|
182
|
+
|
|
183
|
+
def Error(self, message:str):
|
|
184
|
+
self.logger.error(message)
|
|
185
|
+
|
|
186
|
+
def Parse(self, msg: dict, baseModel: dict)-> dict:
|
|
187
|
+
"""
|
|
188
|
+
takes one dict and compares it to a template
|
|
189
|
+
:param msg:
|
|
190
|
+
:param baseModel:
|
|
191
|
+
:return:
|
|
192
|
+
"""
|
|
193
|
+
if not isinstance(msg, dict) or set(msg) - set(baseModel): # turn the dict into a set(storing the keys) and compare with base model
|
|
194
|
+
raise ParsingError("msg inst a dict or has forbidden fields")
|
|
195
|
+
output = {}
|
|
196
|
+
|
|
197
|
+
for name , spec in baseModel.items():
|
|
198
|
+
if name not in msg:
|
|
199
|
+
if spec["required"]:
|
|
200
|
+
raise ParsingError("msg is missing keys defined in template")
|
|
201
|
+
continue
|
|
202
|
+
value = msg[name]
|
|
203
|
+
if spec["type"] is bytes:
|
|
204
|
+
try:
|
|
205
|
+
value = base64.b64decode(value, validate=True)
|
|
206
|
+
except Exception as e:
|
|
207
|
+
raise ParsingError(f"{name} isn't bytes or the field is corrupted")
|
|
208
|
+
|
|
209
|
+
if len(value) != spec["len"]:
|
|
210
|
+
raise ParsingError(f"{name} dosen't match length target")
|
|
211
|
+
elif not isinstance(value, spec["type"]):
|
|
212
|
+
raise ParsingError(f"{name} dosen't match type")
|
|
213
|
+
output[name] = value
|
|
214
|
+
return output
|
|
215
|
+
|
|
216
|
+
def Expire(self, max_age: float = 10.0):
|
|
217
|
+
now = time.monotonic()
|
|
218
|
+
for sid, s in list(self.activeSessions.items()):
|
|
219
|
+
if s.permsLevel == -1 and now - s.created > max_age:
|
|
220
|
+
del self.activeSessions[sid]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def Seal(self, msg:dict, method:str, path:str, sessionId:bytes, ref:int|None = None ):
|
|
224
|
+
""" Turns unencrypted JSON into request ready data
|
|
225
|
+
|
|
226
|
+
:param ref:
|
|
227
|
+
:param sessionId:
|
|
228
|
+
:param session:
|
|
229
|
+
:param msg:
|
|
230
|
+
:param method:
|
|
231
|
+
:param path:
|
|
232
|
+
:return:
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
sec = self.activeSessions[sessionId].session
|
|
237
|
+
except KeyError:
|
|
238
|
+
raise KeyError("[ashtp] Tried using expired session")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
plain = json.dumps(msg).encode()
|
|
242
|
+
tag = _aad(sessionId, method, path, ref=ref)
|
|
243
|
+
n, data = sec.Encrypt(plain,tag)
|
|
244
|
+
|
|
245
|
+
headers = {
|
|
246
|
+
"ashtp-Session": base64.b64encode(sessionId).decode(), # take session id make it into base64 and turn it into words
|
|
247
|
+
"ashtp-Counter": str(n),# the counter needed for encryption/decryption
|
|
248
|
+
"ashtp-Content": "json",# content type
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return data, headers, n
|
|
252
|
+
|
|
253
|
+
def TearOpen(self, msg:tuple[bytes,dict[str,str]], method:str, path:str, clientCount:int, sessionId:bytes, ref:int|None = None) -> tuple[dict,dict]:
|
|
254
|
+
"""
|
|
255
|
+
Turns encrypt byte data into (hopefuly) a readable JSON
|
|
256
|
+
|
|
257
|
+
:param ref:
|
|
258
|
+
:param serverCount:
|
|
259
|
+
:param msg:
|
|
260
|
+
:param method:
|
|
261
|
+
:param path:
|
|
262
|
+
:param session:
|
|
263
|
+
:param sessionId:
|
|
264
|
+
:return:
|
|
265
|
+
"""
|
|
266
|
+
sec = self.activeSessions[sessionId].session
|
|
267
|
+
|
|
268
|
+
path = path if path.startswith("/") else "/" + path
|
|
269
|
+
n = clientCount
|
|
270
|
+
tag = _aad(sessionId, method, path, ref=ref)
|
|
271
|
+
plain = sec.Decrypt(msg[0], n, tag)
|
|
272
|
+
plainJson = json.loads(plain.decode())
|
|
273
|
+
headers = plainJson["user_headers"] | msg[1]
|
|
274
|
+
body = plainJson.pop("user_headers", None)
|
|
275
|
+
|
|
276
|
+
return body , headers
|
|
277
|
+
|
|
278
|
+
def Endpoint(self, path:str, method:str, minPerms:int|None = None, baseModel:dict|None = None, maxBody:int = 16 * 1024):
|
|
279
|
+
method = method.upper()
|
|
280
|
+
|
|
281
|
+
def decorator(fn):
|
|
282
|
+
async def endpoint(req:Request):
|
|
283
|
+
# unpack the request
|
|
284
|
+
try:
|
|
285
|
+
sid = base64.b64decode(req.headers["ashtp-Session"])
|
|
286
|
+
active = self.activeSessions.get(sid)
|
|
287
|
+
requiredPermissionLevel = self.keyAuthority.minPermissionLevel if minPerms is None else minPerms
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
if active is None or not self.keyAuthority.ComparePermissions(active.permsLevel, requiredPermissionLevel):
|
|
291
|
+
return Response(status_code=403)# forbidden request
|
|
292
|
+
|
|
293
|
+
counter = int(req.headers["ashtp-Counter"])
|
|
294
|
+
|
|
295
|
+
contents, headers = self.TearOpen(await _ReadBody(req, maxBody), method, path, counter, sid)
|
|
296
|
+
if baseModel: contents = self.Parse(contents, baseModel)
|
|
297
|
+
|
|
298
|
+
except (KeyError, ValueError, ReplayError, InvalidTag) as e:
|
|
299
|
+
self.Error(f"{path}: {e}")
|
|
300
|
+
return Response(status_code=400) # broken request
|
|
301
|
+
except BodyTooLarge:
|
|
302
|
+
return Response(status_code=413) # body too large
|
|
303
|
+
|
|
304
|
+
# handle request
|
|
305
|
+
try:
|
|
306
|
+
if inspect.iscoroutinefunction(fn):
|
|
307
|
+
result = await fn(active, contents, headers)
|
|
308
|
+
else:
|
|
309
|
+
result = await asyncio.to_thread(fn, active, contents, headers)
|
|
310
|
+
except Exception as e:
|
|
311
|
+
self.Error(f"{path} handler failed: {e}")
|
|
312
|
+
return Response(status_code=500) # random error
|
|
313
|
+
try:
|
|
314
|
+
data, headers, _ = self.Seal(result, method, path, sid, ref=counter)
|
|
315
|
+
return Response(content=data, headers=headers, media_type="application/octet-stream")
|
|
316
|
+
except KeyError as e:
|
|
317
|
+
self.Log(str(e))
|
|
318
|
+
return Response(status_code=401) # Unotorised
|
|
319
|
+
|
|
320
|
+
self.app.add_api_route(path, endpoint, methods=[method])
|
|
321
|
+
return fn
|
|
322
|
+
return decorator
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ------------ endpoints ---------------
|
|
326
|
+
|
|
327
|
+
async def Health(self):
|
|
328
|
+
return {"code": 200, "message": "ok"}
|
|
329
|
+
|
|
330
|
+
async def Handshake(self, request: Request):
|
|
331
|
+
|
|
332
|
+
if self.hsLimit.Blocked("*"):
|
|
333
|
+
return Response(status_code=429)# rate limit exceeded
|
|
334
|
+
self.hsLimit.Hit("*")
|
|
335
|
+
self.Expire() # remove old sessions to prevent overflow
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
body = await _ReadBody(request, 1024)
|
|
339
|
+
raw = self.Parse(json.loads(body), self.HELLO_FORMAT)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
proto = raw["protocol"]
|
|
343
|
+
key_id = raw["spk_hash"]
|
|
344
|
+
|
|
345
|
+
if proto not in SUPPORTED_SERVER_VERSIONS: # check that the version matches
|
|
346
|
+
raise ClientTooOldError("Client is not supported")
|
|
347
|
+
if key_id != hashlib.blake2b(self.Sb, digest_size=8).digest(): # check the key matches
|
|
348
|
+
raise HandshakeError("Wrong/old public key")
|
|
349
|
+
|
|
350
|
+
Eb = raw["E"] # clients public bytes
|
|
351
|
+
|
|
352
|
+
sE = self.s.exchange(X25519PublicKey.from_public_bytes(Eb)) # mix our const private with Clients public
|
|
353
|
+
|
|
354
|
+
f = X25519PrivateKey.generate()
|
|
355
|
+
F = f.public_key()
|
|
356
|
+
Fb = F.public_bytes(
|
|
357
|
+
encoding=serialization.Encoding.Raw,
|
|
358
|
+
format=serialization.PublicFormat.Raw
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
sessionId = secrets.token_bytes(16) # make a session id
|
|
362
|
+
|
|
363
|
+
fE = f.exchange(X25519PublicKey.from_public_bytes(Eb))
|
|
364
|
+
|
|
365
|
+
c2s, s2c = _DeriveKeys(sE + fE, proto.encode() + key_id + Eb + Fb + self.Sb + sessionId)
|
|
366
|
+
session = Session(s2c, c2s)
|
|
367
|
+
|
|
368
|
+
if len(self.activeSessions) >= self.maxSessions:
|
|
369
|
+
unauth = [(s.created, k) for k, s in self.activeSessions.items() if s.permsLevel < 0]
|
|
370
|
+
if not unauth:
|
|
371
|
+
return Response(status_code=503) # if we have all aslots taken refuse service
|
|
372
|
+
del self.activeSessions[min(unauth)[1]]
|
|
373
|
+
|
|
374
|
+
self.activeSessions[sessionId] = ActiveSession(session=session)
|
|
375
|
+
n, ct = session.Encrypt(b"lets go", b"")
|
|
376
|
+
reply = {
|
|
377
|
+
"F": base64.b64encode( Fb ).decode(),
|
|
378
|
+
"session_id": base64.b64encode( sessionId ).decode(),
|
|
379
|
+
"ok": base64.b64encode( ct ).decode()
|
|
380
|
+
}
|
|
381
|
+
return reply
|
|
382
|
+
except BodyTooLarge as e:
|
|
383
|
+
return Response(status_code=413) #body to large
|
|
384
|
+
except (ValueError, ParsingError, HandshakeError) as e:
|
|
385
|
+
self.Error(str(e))
|
|
386
|
+
return Response(status_code=400) # bad request
|
|
387
|
+
except ClientTooOldError:
|
|
388
|
+
return Response(status_code=426, headers={"ashtp-Upgrade": PROTOCOL_VERSION})
|
|
389
|
+
|
|
390
|
+
async def Authenticate(self, request: Request):
|
|
391
|
+
try:
|
|
392
|
+
raw = await _ReadBody(request, 2 * 1024)
|
|
393
|
+
|
|
394
|
+
sid = base64.b64decode(request.headers["ashtp-Session"])
|
|
395
|
+
active = self.activeSessions.get(sid)
|
|
396
|
+
if active is None:
|
|
397
|
+
raise HandshakeError("unknown session")
|
|
398
|
+
|
|
399
|
+
count = int(request.headers["ashtp-Counter"])
|
|
400
|
+
|
|
401
|
+
req = self.TearOpen(raw, "POST", self.serverAuthEndpoint, count, sid)
|
|
402
|
+
req = self.Parse(req, self.AUTH_FORMAT)
|
|
403
|
+
|
|
404
|
+
apiKey = req["api_key"]
|
|
405
|
+
allowed, permissionLevel, name = await self.keyAuthority.CheckPerms(apiKey)
|
|
406
|
+
if allowed:
|
|
407
|
+
active.permsLevel = permissionLevel
|
|
408
|
+
active.name = name
|
|
409
|
+
|
|
410
|
+
data, headers, n = self.Seal({"ok": "ok"}, "POST", self.serverAuthEndpoint, sid, count)
|
|
411
|
+
return Response(content=data, headers=headers)
|
|
412
|
+
else:
|
|
413
|
+
self.activeSessions.pop(sid)
|
|
414
|
+
self.authFails.Hit("*")
|
|
415
|
+
await asyncio.sleep(min(1 + 0.2 * self.authFails.Count("*"), 10))
|
|
416
|
+
raise KeyLacksPermissionError("Key dosen't have enough permissions or dosen't match")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
except BodyTooLarge:
|
|
420
|
+
return Response(status_code=413) # too large bish
|
|
421
|
+
except (KeyError, ValueError, ParsingError, HandshakeError, ReplayError, InvalidTag) as e:
|
|
422
|
+
self.Error(str(e))
|
|
423
|
+
return Response(status_code=400)# bad req
|
|
424
|
+
except KeyLacksPermissionError:
|
|
425
|
+
return Response(status_code=403)# no no perms
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
class IKeyAuthority(ABC):
|
|
4
|
+
@property
|
|
5
|
+
@abstractmethod
|
|
6
|
+
def minPermissionLevel(self) -> int: ...
|
|
7
|
+
|
|
8
|
+
@abstractmethod
|
|
9
|
+
async def CheckPerms(self, apiKey: str) -> tuple[bool, int, str]:
|
|
10
|
+
"""
|
|
11
|
+
Look up an API key.
|
|
12
|
+
Defined as async because DB lookup is expected.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
bool : whether the key meets minimum permission limit,
|
|
16
|
+
int : permission level of the key,
|
|
17
|
+
name : the username under which the key is registered
|
|
18
|
+
|
|
19
|
+
:param apiKey: The key of the client used to identify it
|
|
20
|
+
:returns:
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def ComparePermissions(self, clientPerm: int, requiredPerm: int) -> bool:
|
|
25
|
+
"""
|
|
26
|
+
Decide whether a client's permission level satisfies a required level.
|
|
27
|
+
|
|
28
|
+
What "satisfies" means depends on the application, so subclasses define it.
|
|
29
|
+
|
|
30
|
+
:param clientPerm: The permission level of the client making the request.
|
|
31
|
+
:param requiredPerm: The permission level the action requires.
|
|
32
|
+
:return: True if the client is allowed, False if not.
|
|
33
|
+
"""
|
|
34
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
path = Path(__file__).resolve().parent
|
|
6
|
+
|
|
7
|
+
def Generate():
|
|
8
|
+
"""Standalone utility. Not intended to be called from production code.
|
|
9
|
+
|
|
10
|
+
Generates a fresh key pair and writes the key files next to this file.
|
|
11
|
+
"""
|
|
12
|
+
priv = X25519PrivateKey.generate()
|
|
13
|
+
privB = priv.private_bytes_raw()
|
|
14
|
+
pubB = priv.public_key().public_bytes_raw()
|
|
15
|
+
|
|
16
|
+
(path / "public_key.bin").write_bytes(pubB)
|
|
17
|
+
(path / "private_key.bin").write_bytes(privB)
|
|
18
|
+
|
|
19
|
+
Generate()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
|
|
4
|
+
class ArtysEventManager:
|
|
5
|
+
_events: dict[str, list] = {}
|
|
6
|
+
_getters: dict[str,list] = {}
|
|
7
|
+
|
|
8
|
+
@classmethod
|
|
9
|
+
def Subscribe(cls, eventName:str):
|
|
10
|
+
def decorator(func):
|
|
11
|
+
cls._events.setdefault(eventName, []).append(func)
|
|
12
|
+
return func
|
|
13
|
+
return decorator
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def On(cls, eventName: str, func):
|
|
17
|
+
cls._events.setdefault(eventName, []).append(func)
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def EmitEvent(cls, eventName:str, *args, **kwargs):
|
|
21
|
+
for func in cls._events.get(eventName, []):
|
|
22
|
+
if inspect.iscoroutinefunction(func):
|
|
23
|
+
asyncio.create_task(func(*args, **kwargs))
|
|
24
|
+
else:
|
|
25
|
+
func(*args, **kwargs)
|
|
26
|
+
|
|
27
|
+
def RegisterGetter(self, name, variable):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
class ArtysLogger:
|
|
4
|
+
|
|
5
|
+
logging.basicConfig(
|
|
6
|
+
level=logging.DEBUG,
|
|
7
|
+
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
|
8
|
+
filename="app.log"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def getLog(cls,name):
|
|
13
|
+
return logging.getLogger(name)
|
|
14
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Callable, Any
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class ParsedCommand:
|
|
7
|
+
name:str
|
|
8
|
+
args:list[str] = field(default_factory=list)
|
|
9
|
+
flags:dict[str,Any] = field(default_factory=dict)
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class _Registered:
|
|
13
|
+
func: Callable
|
|
14
|
+
min_args: int = 0
|
|
15
|
+
help: str = ""
|
|
16
|
+
|
|
17
|
+
class CommandError(Exception):
|
|
18
|
+
"""user-facing error (unknown cmd, bad args, ...)."""
|
|
19
|
+
|
|
20
|
+
class Cap:
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self._commands: dict[str, _Registered] = {}
|
|
23
|
+
|
|
24
|
+
def command(self, name: str, min_agrs: int = 0, helpStr: str = ""):
|
|
25
|
+
def decorator(func: Callable):
|
|
26
|
+
self._commands[name] = _Registered(func, min_agrs, helpStr)
|
|
27
|
+
return func
|
|
28
|
+
return decorator
|
|
29
|
+
|
|
30
|
+
def parse(self, raw: str) -> ParsedCommand:
|
|
31
|
+
try:
|
|
32
|
+
tokens = shlex.split(raw,posix=False)
|
|
33
|
+
except ValueError as e:
|
|
34
|
+
raise CommandError(f"bad quoting: {e}")
|
|
35
|
+
|
|
36
|
+
if not tokens:
|
|
37
|
+
raise CommandError("empty command")
|
|
38
|
+
|
|
39
|
+
name, rest = tokens[0], tokens[1:]
|
|
40
|
+
args:list[str] = []
|
|
41
|
+
flags: dict[str, Any] = {}
|
|
42
|
+
|
|
43
|
+
i = 0
|
|
44
|
+
while i < len(rest):
|
|
45
|
+
tok = rest[i]
|
|
46
|
+
if tok.startswith("--"):
|
|
47
|
+
if "=" in tok:
|
|
48
|
+
k, v = tok[2:].split("=", 1)
|
|
49
|
+
flags[k] = v
|
|
50
|
+
else:
|
|
51
|
+
flags[tok[2:]] = True
|
|
52
|
+
else:
|
|
53
|
+
args.append(tok)
|
|
54
|
+
i += 1
|
|
55
|
+
|
|
56
|
+
return ParsedCommand(name, args, flags)
|
|
57
|
+
|
|
58
|
+
def run(self, command: str):
|
|
59
|
+
parsed = self.parse(command)
|
|
60
|
+
reg = self._commands.get(parsed.name)
|
|
61
|
+
if reg is None:
|
|
62
|
+
raise CommandError(f"unknown command: {parsed.name}")
|
|
63
|
+
if len(parsed.args) < reg.min_args:
|
|
64
|
+
raise CommandError(f"'{parsed.name}' needs at least {reg.min_args} arg(s)")
|
|
65
|
+
return reg.func(parsed.args, parsed.flags)
|
|
66
|
+
|