fognode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fognode/__init__.py +120 -0
- fognode/__main__.py +5 -0
- fognode/app.py +270 -0
- fognode/auth/__init__.py +5 -0
- fognode/auth/handshake.py +107 -0
- fognode/ciphers/__init__.py +57 -0
- fognode/ciphers/aesgcm.py +5 -0
- fognode/ciphers/blake2.py +14 -0
- fognode/ciphers/blowfish.py +32 -0
- fognode/ciphers/chacha20.py +23 -0
- fognode/ciphers/ed25519.py +23 -0
- fognode/ciphers/fernet.py +23 -0
- fognode/ciphers/hkdf.py +5 -0
- fognode/ciphers/hmac.py +5 -0
- fognode/ciphers/pbkdf2.py +5 -0
- fognode/ciphers/rsa.py +61 -0
- fognode/ciphers/scrypt.py +15 -0
- fognode/ciphers/sha3.py +18 -0
- fognode/ciphers/x25519.py +5 -0
- fognode/cli/__init__.py +5 -0
- fognode/cli/entrypoint.py +398 -0
- fognode/core/__init__.py +8 -0
- fognode/core/client.py +54 -0
- fognode/core/probe.py +42 -0
- fognode/core/server.py +182 -0
- fognode/core/state.py +66 -0
- fognode/crypto/__init__.py +43 -0
- fognode/crypto/cert.py +159 -0
- fognode/crypto/channel.py +82 -0
- fognode/crypto/kdf.py +7 -0
- fognode/crypto/kx.py +15 -0
- fognode/crypto/password.py +21 -0
- fognode/crypto/primitives.py +63 -0
- fognode/decorators.py +66 -0
- fognode/exceptions.py +12 -0
- fognode/filters/__init__.py +7 -0
- fognode/filters/base.py +8 -0
- fognode/filters/command.py +15 -0
- fognode/filters/text.py +11 -0
- fognode/handlers/__init__.py +5 -0
- fognode/handlers/handler.py +36 -0
- fognode/types/__init__.py +105 -0
- fognode/types/constants.py +25 -0
- fognode/types/exceptions.py +25 -0
- fognode/types/protocol.py +128 -0
- fognode/utils/__init__.py +7 -0
- fognode/utils/ipwords.py +286 -0
- fognode/utils/net.py +12 -0
- fognode/utils/ratelimit.py +43 -0
- fognode/wire/__init__.py +5 -0
- fognode/wire/framing.py +33 -0
- fognode-0.1.0.dist-info/METADATA +150 -0
- fognode-0.1.0.dist-info/RECORD +56 -0
- fognode-0.1.0.dist-info/WHEEL +4 -0
- fognode-0.1.0.dist-info/entry_points.txt +2 -0
- fognode-0.1.0.dist-info/licenses/LICENSE +21 -0
fognode/__init__.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fognode import ciphers, decorators, exceptions, filters, types
|
|
4
|
+
from fognode.app import App, Context, Message, Router
|
|
5
|
+
from fognode.core.client import client_connect
|
|
6
|
+
from fognode.core.server import start_server
|
|
7
|
+
from fognode.core.state import ChatState
|
|
8
|
+
from fognode.crypto.channel import SecureChannel
|
|
9
|
+
from fognode.types import (
|
|
10
|
+
AESGCM_NONCE_LENGTH,
|
|
11
|
+
CERT_VALIDITY_DAYS,
|
|
12
|
+
CHAT_HISTORY_LIMIT,
|
|
13
|
+
CHAT_MAX_TEXT_LENGTH,
|
|
14
|
+
DEFAULT_HOST,
|
|
15
|
+
DEFAULT_PORT,
|
|
16
|
+
MAX_MESSAGE_SIZE,
|
|
17
|
+
NONCE_LENGTH,
|
|
18
|
+
PBKDF2_ITERATIONS,
|
|
19
|
+
RATE_LIMIT_MAX_ATTEMPTS,
|
|
20
|
+
RATE_LIMIT_WINDOW,
|
|
21
|
+
REPLAY_WINDOW,
|
|
22
|
+
RSA_KEY_SIZE,
|
|
23
|
+
SALT_LENGTH,
|
|
24
|
+
TOKEN_LENGTH,
|
|
25
|
+
AuthError,
|
|
26
|
+
AuthResult,
|
|
27
|
+
ByeMsg,
|
|
28
|
+
Bytes32,
|
|
29
|
+
CertError,
|
|
30
|
+
ChallengeMsg,
|
|
31
|
+
ChatMsg,
|
|
32
|
+
ChatMsgDict,
|
|
33
|
+
Cipher,
|
|
34
|
+
CmdMsg,
|
|
35
|
+
CodeName,
|
|
36
|
+
ConnectionInfo,
|
|
37
|
+
ConnectString,
|
|
38
|
+
Fingerprint,
|
|
39
|
+
FrameError,
|
|
40
|
+
HandshakeError,
|
|
41
|
+
HistoryMsg,
|
|
42
|
+
InfoMsg,
|
|
43
|
+
IPAddress,
|
|
44
|
+
MessageHandler,
|
|
45
|
+
OnConnect,
|
|
46
|
+
OnDisconnect,
|
|
47
|
+
OnMessage,
|
|
48
|
+
PongMsg,
|
|
49
|
+
Port,
|
|
50
|
+
ResponseMsg,
|
|
51
|
+
SecurityError,
|
|
52
|
+
ServerInfo,
|
|
53
|
+
User,
|
|
54
|
+
UsersMsg,
|
|
55
|
+
WelcomeMsg,
|
|
56
|
+
WireError,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"AESGCM_NONCE_LENGTH",
|
|
61
|
+
"App",
|
|
62
|
+
"AuthError",
|
|
63
|
+
"AuthResult",
|
|
64
|
+
"Bytes32",
|
|
65
|
+
"ByeMsg",
|
|
66
|
+
"CERT_VALIDITY_DAYS",
|
|
67
|
+
"CHAT_HISTORY_LIMIT",
|
|
68
|
+
"CHAT_MAX_TEXT_LENGTH",
|
|
69
|
+
"CertError",
|
|
70
|
+
"ChallengeMsg",
|
|
71
|
+
"ChatMsg",
|
|
72
|
+
"ChatMsgDict",
|
|
73
|
+
"ChatState",
|
|
74
|
+
"Cipher",
|
|
75
|
+
"CmdMsg",
|
|
76
|
+
"CodeName",
|
|
77
|
+
"ConnectString",
|
|
78
|
+
"ConnectionInfo",
|
|
79
|
+
"Context",
|
|
80
|
+
"DEFAULT_HOST",
|
|
81
|
+
"DEFAULT_PORT",
|
|
82
|
+
"Fingerprint",
|
|
83
|
+
"FrameError",
|
|
84
|
+
"HandshakeError",
|
|
85
|
+
"HistoryMsg",
|
|
86
|
+
"InfoMsg",
|
|
87
|
+
"IPAddress",
|
|
88
|
+
"MAX_MESSAGE_SIZE",
|
|
89
|
+
"Message",
|
|
90
|
+
"MessageHandler",
|
|
91
|
+
"NONCE_LENGTH",
|
|
92
|
+
"OnConnect",
|
|
93
|
+
"OnDisconnect",
|
|
94
|
+
"OnMessage",
|
|
95
|
+
"PBKDF2_ITERATIONS",
|
|
96
|
+
"PongMsg",
|
|
97
|
+
"Port",
|
|
98
|
+
"RATE_LIMIT_MAX_ATTEMPTS",
|
|
99
|
+
"RATE_LIMIT_WINDOW",
|
|
100
|
+
"REPLAY_WINDOW",
|
|
101
|
+
"RSA_KEY_SIZE",
|
|
102
|
+
"ResponseMsg",
|
|
103
|
+
"Router",
|
|
104
|
+
"SALT_LENGTH",
|
|
105
|
+
"SecureChannel",
|
|
106
|
+
"SecurityError",
|
|
107
|
+
"ServerInfo",
|
|
108
|
+
"TOKEN_LENGTH",
|
|
109
|
+
"User",
|
|
110
|
+
"UsersMsg",
|
|
111
|
+
"WelcomeMsg",
|
|
112
|
+
"WireError",
|
|
113
|
+
"ciphers",
|
|
114
|
+
"client_connect",
|
|
115
|
+
"decorators",
|
|
116
|
+
"exceptions",
|
|
117
|
+
"filters",
|
|
118
|
+
"start_server",
|
|
119
|
+
"types",
|
|
120
|
+
]
|
fognode/__main__.py
ADDED
fognode/app.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import threading
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
7
|
+
|
|
8
|
+
from fognode.core.client import client_connect
|
|
9
|
+
from fognode.core.server import _state, start_server
|
|
10
|
+
from fognode.crypto.channel import SecureChannel
|
|
11
|
+
from fognode.filters import Command
|
|
12
|
+
from fognode.handlers import HandlerObject
|
|
13
|
+
from fognode.types.constants import DEFAULT_HOST, DEFAULT_PORT, Cipher
|
|
14
|
+
from fognode.types.protocol import ChatMsg
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from fognode.app import App
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True, frozen=True)
|
|
21
|
+
class Message:
|
|
22
|
+
type: str
|
|
23
|
+
user: str
|
|
24
|
+
text: str
|
|
25
|
+
ts: float
|
|
26
|
+
raw: dict[str, Any]
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_dict(cls, data: dict[str, Any]) -> Message:
|
|
30
|
+
return cls(
|
|
31
|
+
type=data.get("type", ""),
|
|
32
|
+
user=data.get("user", ""),
|
|
33
|
+
text=data.get("text", ""),
|
|
34
|
+
ts=data.get("ts", 0.0),
|
|
35
|
+
raw=data,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class Context:
|
|
41
|
+
app: App
|
|
42
|
+
channel: SecureChannel | None
|
|
43
|
+
user: str
|
|
44
|
+
message: Message | None = None
|
|
45
|
+
|
|
46
|
+
async def answer(self, text: str) -> None:
|
|
47
|
+
if self.channel is None:
|
|
48
|
+
raise RuntimeError("no channel available")
|
|
49
|
+
self.channel.send({"type": "chat", "text": text})
|
|
50
|
+
|
|
51
|
+
async def send(self, data: dict[str, Any]) -> None:
|
|
52
|
+
if self.channel is None:
|
|
53
|
+
raise RuntimeError("no channel available")
|
|
54
|
+
self.channel.send(data)
|
|
55
|
+
|
|
56
|
+
async def reply(self, data: dict[str, Any]) -> None:
|
|
57
|
+
if self.channel is None:
|
|
58
|
+
raise RuntimeError("no channel available")
|
|
59
|
+
self.channel.send(data)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Router:
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
self._handlers: dict[str, list[HandlerObject]] = {
|
|
65
|
+
"connect": [],
|
|
66
|
+
"disconnect": [],
|
|
67
|
+
"message": [],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
def on_connect(self) -> Callable[..., Any]:
|
|
71
|
+
def decorator(callback: Callable[..., Any]) -> Callable[..., Any]:
|
|
72
|
+
self._handlers["connect"].append(HandlerObject(callback))
|
|
73
|
+
return callback
|
|
74
|
+
|
|
75
|
+
return decorator
|
|
76
|
+
|
|
77
|
+
def on_disconnect(self) -> Callable[..., Any]:
|
|
78
|
+
def decorator(callback: Callable[..., Any]) -> Callable[..., Any]:
|
|
79
|
+
self._handlers["disconnect"].append(HandlerObject(callback))
|
|
80
|
+
return callback
|
|
81
|
+
|
|
82
|
+
return decorator
|
|
83
|
+
|
|
84
|
+
def on_message(self, *filters: Any) -> Callable[..., Any]:
|
|
85
|
+
def decorator(callback: Callable[..., Any]) -> Callable[..., Any]:
|
|
86
|
+
self._handlers["message"].append(HandlerObject(callback, list(filters)))
|
|
87
|
+
return callback
|
|
88
|
+
|
|
89
|
+
return decorator
|
|
90
|
+
|
|
91
|
+
def on_command(self, commands: str | list[str]) -> Callable[..., Any]:
|
|
92
|
+
def decorator(callback: Callable[..., Any]) -> Callable[..., Any]:
|
|
93
|
+
self._handlers["message"].append(HandlerObject(callback, [Command(commands)]))
|
|
94
|
+
return callback
|
|
95
|
+
|
|
96
|
+
return decorator
|
|
97
|
+
|
|
98
|
+
def include(self, app: App) -> None:
|
|
99
|
+
for event, handlers in self._handlers.items():
|
|
100
|
+
app._handlers[event].extend(handlers)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class App:
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
host: str = DEFAULT_HOST,
|
|
107
|
+
port: int = DEFAULT_PORT,
|
|
108
|
+
user: str | None = None,
|
|
109
|
+
password: str | None = None,
|
|
110
|
+
cipher: Cipher = Cipher.AESGCM,
|
|
111
|
+
mode: str = "server",
|
|
112
|
+
connect_string: str | None = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
self.host = host
|
|
115
|
+
self.port = port
|
|
116
|
+
self.user = user
|
|
117
|
+
self.password = password
|
|
118
|
+
self.cipher = cipher
|
|
119
|
+
self.mode = mode
|
|
120
|
+
self.connect_string = connect_string
|
|
121
|
+
self._handlers: dict[str, list[HandlerObject]] = {
|
|
122
|
+
"connect": [],
|
|
123
|
+
"disconnect": [],
|
|
124
|
+
"message": [],
|
|
125
|
+
}
|
|
126
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
127
|
+
self._channel: SecureChannel | None = None
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def client(
|
|
131
|
+
cls,
|
|
132
|
+
connect_string: str,
|
|
133
|
+
password: str,
|
|
134
|
+
cipher: Cipher = Cipher.AESGCM,
|
|
135
|
+
) -> App:
|
|
136
|
+
return cls(
|
|
137
|
+
mode="client",
|
|
138
|
+
connect_string=connect_string,
|
|
139
|
+
password=password,
|
|
140
|
+
cipher=cipher,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def on_connect(self) -> Callable[..., Any]:
|
|
144
|
+
def decorator(callback: Callable[..., Any]) -> Callable[..., Any]:
|
|
145
|
+
self._handlers["connect"].append(HandlerObject(callback))
|
|
146
|
+
return callback
|
|
147
|
+
|
|
148
|
+
return decorator
|
|
149
|
+
|
|
150
|
+
def on_disconnect(self) -> Callable[..., Any]:
|
|
151
|
+
def decorator(callback: Callable[..., Any]) -> Callable[..., Any]:
|
|
152
|
+
self._handlers["disconnect"].append(HandlerObject(callback))
|
|
153
|
+
return callback
|
|
154
|
+
|
|
155
|
+
return decorator
|
|
156
|
+
|
|
157
|
+
def on_message(self, *filters: Any) -> Callable[..., Any]:
|
|
158
|
+
def decorator(callback: Callable[..., Any]) -> Callable[..., Any]:
|
|
159
|
+
self._handlers["message"].append(HandlerObject(callback, list(filters)))
|
|
160
|
+
return callback
|
|
161
|
+
|
|
162
|
+
return decorator
|
|
163
|
+
|
|
164
|
+
def on_command(self, commands: str | list[str]) -> Callable[..., Any]:
|
|
165
|
+
def decorator(callback: Callable[..., Any]) -> Callable[..., Any]:
|
|
166
|
+
self._handlers["message"].append(HandlerObject(callback, [Command(commands)]))
|
|
167
|
+
return callback
|
|
168
|
+
|
|
169
|
+
return decorator
|
|
170
|
+
|
|
171
|
+
def include_router(self, router: Router) -> None:
|
|
172
|
+
router.include(self)
|
|
173
|
+
|
|
174
|
+
def run(self) -> None:
|
|
175
|
+
self._loop = asyncio.new_event_loop()
|
|
176
|
+
asyncio.set_event_loop(self._loop)
|
|
177
|
+
if self.mode == "server":
|
|
178
|
+
self._setup_server()
|
|
179
|
+
else:
|
|
180
|
+
self._setup_client()
|
|
181
|
+
try:
|
|
182
|
+
self._loop.run_forever()
|
|
183
|
+
except KeyboardInterrupt:
|
|
184
|
+
pass
|
|
185
|
+
finally:
|
|
186
|
+
self._loop.close()
|
|
187
|
+
|
|
188
|
+
def _setup_server(self) -> None:
|
|
189
|
+
if not self.user or not self.password:
|
|
190
|
+
raise ValueError("user and password required for server mode")
|
|
191
|
+
|
|
192
|
+
def _on_connect(user: str) -> None:
|
|
193
|
+
if self._loop is not None:
|
|
194
|
+
self._loop.call_soon_threadsafe(self._process_connect, user)
|
|
195
|
+
|
|
196
|
+
def _on_disconnect(user: str) -> None:
|
|
197
|
+
if self._loop is not None:
|
|
198
|
+
self._loop.call_soon_threadsafe(self._process_disconnect, user)
|
|
199
|
+
|
|
200
|
+
def _on_msg(msg: ChatMsg) -> None:
|
|
201
|
+
if self._loop is not None:
|
|
202
|
+
self._loop.call_soon_threadsafe(self._process_chat, msg)
|
|
203
|
+
|
|
204
|
+
start_server(
|
|
205
|
+
self.host,
|
|
206
|
+
self.port,
|
|
207
|
+
self.user,
|
|
208
|
+
self.password,
|
|
209
|
+
on_connect=_on_connect,
|
|
210
|
+
on_disconnect=_on_disconnect,
|
|
211
|
+
)
|
|
212
|
+
_state.on_message(_on_msg)
|
|
213
|
+
|
|
214
|
+
def _setup_client(self) -> None:
|
|
215
|
+
if not self.connect_string or not self.password:
|
|
216
|
+
raise ValueError("connect_string and password required for client mode")
|
|
217
|
+
|
|
218
|
+
ch, _fp = client_connect(self.connect_string, self.password)
|
|
219
|
+
self._channel = ch
|
|
220
|
+
|
|
221
|
+
welcome = ch.recv()
|
|
222
|
+
if welcome.get("type") == "welcome":
|
|
223
|
+
for m in welcome.get("history", []):
|
|
224
|
+
self._process_raw_msg(ch, m)
|
|
225
|
+
|
|
226
|
+
def _recv() -> None:
|
|
227
|
+
while True:
|
|
228
|
+
try:
|
|
229
|
+
msg = ch.recv()
|
|
230
|
+
if self._loop is not None:
|
|
231
|
+
self._loop.call_soon_threadsafe(self._process_raw_msg, ch, msg)
|
|
232
|
+
except Exception:
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
threading.Thread(target=_recv, daemon=True).start()
|
|
236
|
+
|
|
237
|
+
def _process_connect(self, user: str) -> None:
|
|
238
|
+
ch = _state.get_channel(user)
|
|
239
|
+
ctx = Context(self, ch, user)
|
|
240
|
+
for handler in self._handlers["connect"]:
|
|
241
|
+
asyncio.create_task(handler.call(ctx))
|
|
242
|
+
|
|
243
|
+
def _process_disconnect(self, user: str) -> None:
|
|
244
|
+
ch = _state.get_channel(user)
|
|
245
|
+
ctx = Context(self, ch, user)
|
|
246
|
+
for handler in self._handlers["disconnect"]:
|
|
247
|
+
asyncio.create_task(handler.call(ctx))
|
|
248
|
+
|
|
249
|
+
def _process_chat(self, m: ChatMsg) -> None:
|
|
250
|
+
ch = _state.get_channel(m.user)
|
|
251
|
+
if ch is None:
|
|
252
|
+
return
|
|
253
|
+
msg = Message.from_dict(m.as_dict())
|
|
254
|
+
ctx = Context(self, ch, m.user, msg)
|
|
255
|
+
for handler in self._handlers["message"]:
|
|
256
|
+
asyncio.create_task(self._run_handler(handler, msg.raw, ctx))
|
|
257
|
+
|
|
258
|
+
def _process_raw_msg(self, ch: SecureChannel, msg: dict[str, Any]) -> None:
|
|
259
|
+
mtype = msg.get("type", "")
|
|
260
|
+
if mtype == "chat":
|
|
261
|
+
message = Message.from_dict(msg)
|
|
262
|
+
ctx = Context(self, ch, msg.get("user", ""), message)
|
|
263
|
+
for handler in self._handlers["message"]:
|
|
264
|
+
asyncio.create_task(self._run_handler(handler, msg, ctx))
|
|
265
|
+
|
|
266
|
+
async def _run_handler(
|
|
267
|
+
self, handler: HandlerObject, data: dict[str, Any], ctx: Context
|
|
268
|
+
) -> None:
|
|
269
|
+
if await handler.check(data):
|
|
270
|
+
await handler.call(ctx)
|
fognode/auth/__init__.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import os
|
|
5
|
+
import ssl
|
|
6
|
+
|
|
7
|
+
from fognode.crypto.cert import cert_fingerprint, cert_paths
|
|
8
|
+
from fognode.crypto.channel import SecureChannel
|
|
9
|
+
from fognode.crypto.kx import x25519_keypair, x25519_shared
|
|
10
|
+
from fognode.crypto.password import load_password_key
|
|
11
|
+
from fognode.crypto.primitives import hkdf_expand, hmac256, hmac256_verify, pbkdf2
|
|
12
|
+
from fognode.types.constants import NONCE_LENGTH, TOKEN_LENGTH
|
|
13
|
+
from fognode.types.exceptions import AuthError, SecurityError
|
|
14
|
+
from fognode.types.protocol import IPAddress, User
|
|
15
|
+
from fognode.utils.ratelimit import RateLimiter
|
|
16
|
+
from fognode.wire.framing import wire_recv, wire_send
|
|
17
|
+
|
|
18
|
+
_rl = RateLimiter()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def server_handshake(
|
|
22
|
+
tls: ssl.SSLSocket, client_ip: IPAddress, expected_user: User
|
|
23
|
+
) -> SecureChannel:
|
|
24
|
+
salt, stored_key = load_password_key()
|
|
25
|
+
server_fp = cert_fingerprint(cert_paths()[0])
|
|
26
|
+
nonce = os.urandom(NONCE_LENGTH)
|
|
27
|
+
sv_pub, sv_priv = x25519_keypair()
|
|
28
|
+
|
|
29
|
+
wire_send(
|
|
30
|
+
tls,
|
|
31
|
+
{
|
|
32
|
+
"type": "challenge",
|
|
33
|
+
"nonce": nonce.hex(),
|
|
34
|
+
"salt": salt.hex(),
|
|
35
|
+
"fp": server_fp,
|
|
36
|
+
"ecdh_pub": base64.b64encode(sv_pub).decode(),
|
|
37
|
+
"ver": "1",
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
resp = wire_recv(tls)
|
|
42
|
+
client_user = resp.get("user", "")
|
|
43
|
+
hmac_resp = bytes.fromhex(resp.get("hmac_resp", ""))
|
|
44
|
+
client_fp = resp.get("cert_fp", "")
|
|
45
|
+
cl_pub = base64.b64decode(resp.get("ecdh_pub", ""))
|
|
46
|
+
|
|
47
|
+
if client_fp.upper() != server_fp.upper():
|
|
48
|
+
wire_send(tls, {"ok": False, "error": "fp_mismatch"})
|
|
49
|
+
raise AuthError("fp mismatch")
|
|
50
|
+
|
|
51
|
+
if client_user.encode() != expected_user.encode():
|
|
52
|
+
wire_send(tls, {"ok": False, "error": "bad_user"})
|
|
53
|
+
raise AuthError("bad user")
|
|
54
|
+
|
|
55
|
+
if not hmac256_verify(nonce, stored_key, hmac_resp):
|
|
56
|
+
wire_send(tls, {"ok": False, "error": "bad_password"})
|
|
57
|
+
raise AuthError("bad password")
|
|
58
|
+
|
|
59
|
+
session_token = os.urandom(TOKEN_LENGTH)
|
|
60
|
+
shared = x25519_shared(sv_priv, cl_pub)
|
|
61
|
+
key_material = hkdf_expand(shared, session_token, b"fognode_v1_channel_keys", length=64)
|
|
62
|
+
enc_key, mac_key = key_material[:32], key_material[32:]
|
|
63
|
+
|
|
64
|
+
wire_send(tls, {"ok": True, "token": session_token.hex()})
|
|
65
|
+
_rl.ok(client_ip)
|
|
66
|
+
return SecureChannel(tls, enc_key, mac_key)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def client_handshake(
|
|
70
|
+
tls: ssl.SSLSocket, username: User, password: str, measured_fp: str
|
|
71
|
+
) -> SecureChannel:
|
|
72
|
+
chal = wire_recv(tls)
|
|
73
|
+
if chal.get("type") != "challenge":
|
|
74
|
+
raise ConnectionError("expected challenge")
|
|
75
|
+
|
|
76
|
+
nonce = bytes.fromhex(chal["nonce"])
|
|
77
|
+
salt = bytes.fromhex(chal["salt"])
|
|
78
|
+
server_fp = chal.get("fp", "")
|
|
79
|
+
sv_pub = base64.b64decode(chal.get("ecdh_pub", ""))
|
|
80
|
+
|
|
81
|
+
if measured_fp.upper() != server_fp.upper():
|
|
82
|
+
raise SecurityError("fingerprint mismatch")
|
|
83
|
+
|
|
84
|
+
key = pbkdf2(password, salt)
|
|
85
|
+
hmac_resp = hmac256(nonce, key)
|
|
86
|
+
cl_pub, cl_priv = x25519_keypair()
|
|
87
|
+
|
|
88
|
+
wire_send(
|
|
89
|
+
tls,
|
|
90
|
+
{
|
|
91
|
+
"user": username,
|
|
92
|
+
"hmac_resp": hmac_resp.hex(),
|
|
93
|
+
"cert_fp": measured_fp,
|
|
94
|
+
"ecdh_pub": base64.b64encode(cl_pub).decode(),
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
result = wire_recv(tls)
|
|
99
|
+
if not result.get("ok"):
|
|
100
|
+
raise AuthError(f"auth rejected: {result.get('error')}")
|
|
101
|
+
|
|
102
|
+
session_token = bytes.fromhex(result["token"])
|
|
103
|
+
shared = x25519_shared(cl_priv, sv_pub)
|
|
104
|
+
key_material = hkdf_expand(shared, session_token, b"fognode_v1_channel_keys", length=64)
|
|
105
|
+
enc_key, mac_key = key_material[:32], key_material[32:]
|
|
106
|
+
|
|
107
|
+
return SecureChannel(tls, enc_key, mac_key)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from fognode.ciphers.aesgcm import aes_decrypt, aes_encrypt
|
|
4
|
+
from fognode.ciphers.blake2 import blake2b, blake2s
|
|
5
|
+
from fognode.ciphers.blowfish import blowfish_decrypt, blowfish_encrypt
|
|
6
|
+
from fognode.ciphers.chacha20 import chacha_decrypt, chacha_encrypt
|
|
7
|
+
from fognode.ciphers.ed25519 import ed25519_generate_key, ed25519_sign, ed25519_verify
|
|
8
|
+
from fognode.ciphers.fernet import fernet_decrypt, fernet_encrypt, fernet_generate_key
|
|
9
|
+
from fognode.ciphers.hkdf import hkdf_expand
|
|
10
|
+
from fognode.ciphers.hmac import hmac256, hmac256_verify
|
|
11
|
+
from fognode.ciphers.pbkdf2 import pbkdf2
|
|
12
|
+
from fognode.ciphers.rsa import (
|
|
13
|
+
rsa_decrypt,
|
|
14
|
+
rsa_encrypt,
|
|
15
|
+
rsa_generate_key,
|
|
16
|
+
rsa_serialize_private,
|
|
17
|
+
rsa_serialize_public,
|
|
18
|
+
rsa_sign,
|
|
19
|
+
rsa_verify,
|
|
20
|
+
)
|
|
21
|
+
from fognode.ciphers.scrypt import scrypt_kdf
|
|
22
|
+
from fognode.ciphers.sha3 import sha3_256, sha3_512, sha512
|
|
23
|
+
from fognode.ciphers.x25519 import x25519_keypair, x25519_shared
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"aes_decrypt",
|
|
27
|
+
"aes_encrypt",
|
|
28
|
+
"blake2b",
|
|
29
|
+
"blake2s",
|
|
30
|
+
"blowfish_decrypt",
|
|
31
|
+
"blowfish_encrypt",
|
|
32
|
+
"chacha_decrypt",
|
|
33
|
+
"chacha_encrypt",
|
|
34
|
+
"ed25519_generate_key",
|
|
35
|
+
"ed25519_sign",
|
|
36
|
+
"ed25519_verify",
|
|
37
|
+
"fernet_decrypt",
|
|
38
|
+
"fernet_encrypt",
|
|
39
|
+
"fernet_generate_key",
|
|
40
|
+
"hkdf_expand",
|
|
41
|
+
"hmac256",
|
|
42
|
+
"hmac256_verify",
|
|
43
|
+
"pbkdf2",
|
|
44
|
+
"rsa_decrypt",
|
|
45
|
+
"rsa_encrypt",
|
|
46
|
+
"rsa_generate_key",
|
|
47
|
+
"rsa_sign",
|
|
48
|
+
"rsa_serialize_private",
|
|
49
|
+
"rsa_serialize_public",
|
|
50
|
+
"rsa_verify",
|
|
51
|
+
"scrypt_kdf",
|
|
52
|
+
"sha3_256",
|
|
53
|
+
"sha3_512",
|
|
54
|
+
"sha512",
|
|
55
|
+
"x25519_keypair",
|
|
56
|
+
"x25519_shared",
|
|
57
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def blake2b(data: bytes, key: bytes = b"") -> bytes:
|
|
7
|
+
return hashlib.blake2b(data, key=key).digest()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def blake2s(data: bytes, key: bytes = b"") -> bytes:
|
|
11
|
+
return hashlib.blake2s(data, key=key).digest()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = ["blake2b", "blake2s"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from cryptography.hazmat.primitives.ciphers import Cipher as _Cipher
|
|
6
|
+
from cryptography.hazmat.primitives.ciphers import algorithms, modes
|
|
7
|
+
|
|
8
|
+
from fognode.types.exceptions import SecurityError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def blowfish_encrypt(key: bytes, plaintext: bytes) -> tuple[bytes, bytes]:
|
|
12
|
+
iv = os.urandom(8)
|
|
13
|
+
cipher = _Cipher(algorithms.Blowfish(key), modes.CBC(iv))
|
|
14
|
+
encryptor = cipher.encryptor()
|
|
15
|
+
pad_len = 8 - (len(plaintext) % 8)
|
|
16
|
+
padded = plaintext + bytes([pad_len] * pad_len)
|
|
17
|
+
ct = encryptor.update(padded) + encryptor.finalize()
|
|
18
|
+
return iv, ct
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def blowfish_decrypt(key: bytes, iv: bytes, ciphertext: bytes) -> bytes:
|
|
22
|
+
try:
|
|
23
|
+
cipher = _Cipher(algorithms.Blowfish(key), modes.CBC(iv))
|
|
24
|
+
decryptor = cipher.decryptor()
|
|
25
|
+
padded = decryptor.update(ciphertext) + decryptor.finalize()
|
|
26
|
+
pad_len = padded[-1]
|
|
27
|
+
return padded[:-pad_len]
|
|
28
|
+
except Exception:
|
|
29
|
+
raise SecurityError("blowfish decrypt failed") from None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = ["blowfish_decrypt", "blowfish_encrypt"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
|
6
|
+
|
|
7
|
+
from fognode.types.exceptions import SecurityError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def chacha_encrypt(key: bytes, plaintext: bytes, aad: bytes) -> tuple[bytes, bytes]:
|
|
11
|
+
nonce = os.urandom(12)
|
|
12
|
+
ct = ChaCha20Poly1305(key).encrypt(nonce, plaintext, aad)
|
|
13
|
+
return nonce, ct
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def chacha_decrypt(key: bytes, nonce: bytes, ciphertext: bytes, aad: bytes) -> bytes:
|
|
17
|
+
try:
|
|
18
|
+
return ChaCha20Poly1305(key).decrypt(nonce, ciphertext, aad)
|
|
19
|
+
except Exception:
|
|
20
|
+
raise SecurityError("ChaCha20-Poly1305 tag invalid") from None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__all__ = ["chacha_decrypt", "chacha_encrypt"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def ed25519_generate_key() -> tuple[Ed25519PrivateKey, Ed25519PublicKey]:
|
|
7
|
+
private_key = Ed25519PrivateKey.generate()
|
|
8
|
+
return private_key, private_key.public_key()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def ed25519_sign(private_key: Ed25519PrivateKey, data: bytes) -> bytes:
|
|
12
|
+
return private_key.sign(data)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def ed25519_verify(public_key: Ed25519PublicKey, data: bytes, signature: bytes) -> bool:
|
|
16
|
+
try:
|
|
17
|
+
public_key.verify(signature, data)
|
|
18
|
+
return True
|
|
19
|
+
except Exception:
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__all__ = ["ed25519_generate_key", "ed25519_sign", "ed25519_verify"]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from cryptography.fernet import Fernet, InvalidToken
|
|
4
|
+
|
|
5
|
+
from fognode.types.exceptions import SecurityError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def fernet_generate_key() -> bytes:
|
|
9
|
+
return Fernet.generate_key()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def fernet_encrypt(key: bytes, plaintext: bytes) -> bytes:
|
|
13
|
+
return Fernet(key).encrypt(plaintext)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fernet_decrypt(key: bytes, ciphertext: bytes) -> bytes:
|
|
17
|
+
try:
|
|
18
|
+
return Fernet(key).decrypt(ciphertext)
|
|
19
|
+
except InvalidToken:
|
|
20
|
+
raise SecurityError("fernet token invalid") from None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
__all__ = ["fernet_decrypt", "fernet_encrypt", "fernet_generate_key"]
|