riptide-transfer 1.3.6__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.
- riptide/__init__.py +28 -0
- riptide/__main__.py +4 -0
- riptide/app.py +45 -0
- riptide/core/__init__.py +1 -0
- riptide/core/config.py +176 -0
- riptide/core/file_select.py +89 -0
- riptide/core/firewall.py +552 -0
- riptide/core/focus.py +82 -0
- riptide/core/identity.py +243 -0
- riptide/core/nat.py +102 -0
- riptide/core/session.py +887 -0
- riptide/core/transfer.py +89 -0
- riptide/screens/__init__.py +1 -0
- riptide/screens/create.py +529 -0
- riptide/screens/join.py +479 -0
- riptide/screens/menu.py +94 -0
- riptide/screens/session_screen.py +1059 -0
- riptide/theme.py +1049 -0
- riptide_transfer-1.3.6.dist-info/METADATA +176 -0
- riptide_transfer-1.3.6.dist-info/RECORD +23 -0
- riptide_transfer-1.3.6.dist-info/WHEEL +5 -0
- riptide_transfer-1.3.6.dist-info/entry_points.txt +2 -0
- riptide_transfer-1.3.6.dist-info/top_level.txt +1 -0
riptide/core/session.py
ADDED
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
"""Create / Join session protocol.
|
|
2
|
+
|
|
3
|
+
One TLS connection, started by the Join side (the client) dialling
|
|
4
|
+
the Create side (the host). After that one connection is live,
|
|
5
|
+
file direction is independent of connection direction:
|
|
6
|
+
|
|
7
|
+
- either side can Send (push a local file to the other side)
|
|
8
|
+
- either side can Download (request a file from the other side)
|
|
9
|
+
|
|
10
|
+
No pairing code, no pairing listener, no SSH. Authentication is
|
|
11
|
+
fingerprint-based, like SSH host keys:
|
|
12
|
+
- the client checks the host certificate fingerprint, and
|
|
13
|
+
presses Allow on first connect, or aborts if it changed;
|
|
14
|
+
- since v1.1, the host also checks the client certificate
|
|
15
|
+
fingerprint, through a signed hello challenge, and presses
|
|
16
|
+
Allow on first connect, shows Known for a matching fingerprint,
|
|
17
|
+
or warns on a changed fingerprint. An empty client fingerprint
|
|
18
|
+
aborts, it never falls back to device-name-only approval.
|
|
19
|
+
|
|
20
|
+
Framing: 4-byte big-endian length + JSON header, then, for a
|
|
21
|
+
file message, exactly size bytes. A file is written to a .part
|
|
22
|
+
file in the receiver's cwd and renamed only after its SHA-256
|
|
23
|
+
matches the header. A mismatch is reported, not renamed.
|
|
24
|
+
|
|
25
|
+
Only one file transfer runs at a time on a session (a send lock
|
|
26
|
+
makes header + bytes atomic). A send does not block waiting for
|
|
27
|
+
its ack: the ack arrives as a normal message and is delivered
|
|
28
|
+
through on_ack, so the reader loop can never deadlock waiting
|
|
29
|
+
for a message it is supposed to read itself.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import base64
|
|
34
|
+
import hashlib
|
|
35
|
+
import inspect
|
|
36
|
+
import ipaddress
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import select
|
|
40
|
+
import socket
|
|
41
|
+
import ssl
|
|
42
|
+
import struct
|
|
43
|
+
import threading
|
|
44
|
+
import time
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
from .config import add_trusted_client, load_config
|
|
48
|
+
from .identity import (
|
|
49
|
+
cert_der_from_pem,
|
|
50
|
+
cert_fingerprint,
|
|
51
|
+
ensure_identity,
|
|
52
|
+
sign_nonce,
|
|
53
|
+
verify_nonce_signature,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_host_input(text: str) -> str:
|
|
58
|
+
"""Parse a Create-server address into a bare host.
|
|
59
|
+
|
|
60
|
+
Accepts, all with no port typed by the user:
|
|
61
|
+
- a hostname: metalvoid.ddns.net
|
|
62
|
+
- an IPv4 address: 192.168.1.10
|
|
63
|
+
- an IPv6 address: 2001:db8::1, or [2001:db8::1]
|
|
64
|
+
- user@host: user@metalvoid.ddns.net,
|
|
65
|
+
user@192.168.1.10, user@[2001:db8::1]
|
|
66
|
+
- host:port, if a port was typed anyway, is stripped for plain
|
|
67
|
+
hostnames / IPv4; a bare IPv6 address is never split on ':'.
|
|
68
|
+
Raises ValueError for an empty or unusable input.
|
|
69
|
+
"""
|
|
70
|
+
text = (text or "").strip()
|
|
71
|
+
if not text:
|
|
72
|
+
raise ValueError("Enter a host first")
|
|
73
|
+
if "@" in text:
|
|
74
|
+
text = text.rsplit("@", 1)[1].strip()
|
|
75
|
+
if not text:
|
|
76
|
+
raise ValueError("Enter a host first")
|
|
77
|
+
if text.startswith("["):
|
|
78
|
+
# [ipv6] or [ipv6]:port
|
|
79
|
+
end = text.find("]")
|
|
80
|
+
if end == -1:
|
|
81
|
+
raise ValueError("Bad IPv6 address: missing ]")
|
|
82
|
+
host = text[1:end].strip()
|
|
83
|
+
if not host:
|
|
84
|
+
raise ValueError("Enter a host first")
|
|
85
|
+
return host
|
|
86
|
+
# Bare IPv6: more than one ':' and a valid IPv6 parse.
|
|
87
|
+
if text.count(":") > 1:
|
|
88
|
+
try:
|
|
89
|
+
ipaddress.IPv6Address(text)
|
|
90
|
+
return text
|
|
91
|
+
except Exception:
|
|
92
|
+
raise ValueError("Bad IPv6 address")
|
|
93
|
+
# Single ':' for hostname/IPv4 with a port: strip the port.
|
|
94
|
+
if text.count(":") == 1:
|
|
95
|
+
host, _port = text.split(":", 1)
|
|
96
|
+
host = host.strip()
|
|
97
|
+
if not host:
|
|
98
|
+
raise ValueError("Enter a host first")
|
|
99
|
+
return host
|
|
100
|
+
return text
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _set_keepalive(sock) -> None:
|
|
104
|
+
"""Blocking socket + TCP keepalive, so an idle session does not
|
|
105
|
+
time out. socket.create_connection leaves a 30-second timeout
|
|
106
|
+
on the client socket, so recv() timed out after 30 seconds of
|
|
107
|
+
no file transfer and the reader loop ended the session.
|
|
108
|
+
Blocking mode + keepalive removes that."""
|
|
109
|
+
try:
|
|
110
|
+
sock.settimeout(None)
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
try:
|
|
114
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
# Linux TCP keepalive tunables; harmless if unavailable (Windows).
|
|
118
|
+
for opt, value in (("TCP_KEEPIDLE", 10), ("TCP_KEEPINTVL", 10), ("TCP_KEEPCNT", 3)):
|
|
119
|
+
try:
|
|
120
|
+
sock.setsockopt(socket.IPPROTO_TCP, getattr(socket, opt), value)
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _recv_exact(sock, n: int) -> bytes:
|
|
126
|
+
data = b""
|
|
127
|
+
while len(data) < n:
|
|
128
|
+
chunk = sock.recv(n - len(data))
|
|
129
|
+
if not chunk:
|
|
130
|
+
raise ConnectionError("session closed")
|
|
131
|
+
data += chunk
|
|
132
|
+
return data
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _send_header(sock, header: dict) -> None:
|
|
136
|
+
raw = json.dumps(header).encode()
|
|
137
|
+
sock.sendall(struct.pack(">I", len(raw)) + raw)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def unique_final_path(cwd: Path, name: str) -> Path | None:
|
|
141
|
+
"""Pick a final path that does not overwrite an existing file.
|
|
142
|
+
|
|
143
|
+
Since v1.2, a received file never silently overwrites. If
|
|
144
|
+
``name`` already exists in ``cwd``, try ``name (1).ext``,
|
|
145
|
+
then ``name (2).ext``, and so on, up to 1000. The extension
|
|
146
|
+
is the last suffix, so ``archive.tar.gz`` becomes
|
|
147
|
+
``archive.tar (1).gz``, and a dotfile ``.bashrc`` becomes
|
|
148
|
+
``.bashrc (1)``. An incoming name that already ends in
|
|
149
|
+
`` (1)`` is treated literally, so it becomes `` (1) (1)``
|
|
150
|
+
if that name is also taken, which keeps the rule simple
|
|
151
|
+
and predictable. A symlink counts as existing, so a symlink
|
|
152
|
+
is never replaced. Returns None after 1000 taken names.
|
|
153
|
+
Empty, dot, and dot-dot names return the original path,
|
|
154
|
+
so the caller fails as before instead of inventing a name.
|
|
155
|
+
"""
|
|
156
|
+
cwd = Path(cwd)
|
|
157
|
+
if not name or name in (".", ".."):
|
|
158
|
+
return cwd / name
|
|
159
|
+
base = Path(name).name
|
|
160
|
+
stem = Path(base).stem
|
|
161
|
+
suffix = Path(base).suffix
|
|
162
|
+
candidate = cwd / base
|
|
163
|
+
if not (candidate.exists() or candidate.is_symlink()):
|
|
164
|
+
return candidate
|
|
165
|
+
for i in range(1, 1001):
|
|
166
|
+
candidate = cwd / f"{stem} ({i}){suffix}"
|
|
167
|
+
if not (candidate.exists() or candidate.is_symlink()):
|
|
168
|
+
return candidate
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class Session:
|
|
173
|
+
def __init__(self, sock, cwd: Path, device: str = "", peer_device: str = "",
|
|
174
|
+
peer_host: str = "",
|
|
175
|
+
on_file_received=None, on_ack=None, on_error=None,
|
|
176
|
+
on_progress=None,
|
|
177
|
+
on_download_ask=None, on_download_denied=None,
|
|
178
|
+
on_disconnect=None):
|
|
179
|
+
self.sock = sock
|
|
180
|
+
self.cwd = Path(cwd)
|
|
181
|
+
self.device = device
|
|
182
|
+
self.peer_device = peer_device
|
|
183
|
+
self.peer_host = peer_host
|
|
184
|
+
self.peer_fingerprint = ""
|
|
185
|
+
# Host side only, set by HostListener: known, new, or changed.
|
|
186
|
+
self.client_trust_status = ""
|
|
187
|
+
self.client_old_fingerprint = ""
|
|
188
|
+
self.on_file_received = on_file_received
|
|
189
|
+
self.on_ack = on_ack
|
|
190
|
+
self.on_error = on_error
|
|
191
|
+
self.on_progress = on_progress
|
|
192
|
+
self.on_download_ask = on_download_ask
|
|
193
|
+
self.on_download_denied = on_download_denied
|
|
194
|
+
# Called exactly once when the session ends, cleanly or not:
|
|
195
|
+
# the peer sent bye, or the reader hit an error. The UI uses
|
|
196
|
+
# it to return to the Create/Join screen. It is separate
|
|
197
|
+
# from on_error because on_error also fires for non-fatal
|
|
198
|
+
# events, like a failed file receive, which must not end
|
|
199
|
+
# the session.
|
|
200
|
+
self.on_disconnect = on_disconnect
|
|
201
|
+
# Set when the reader has exited, so a waiter (the Create
|
|
202
|
+
# screen worker) can tell the session is over without
|
|
203
|
+
# polling.
|
|
204
|
+
self._ended = threading.Event()
|
|
205
|
+
self._send_lock = threading.Lock()
|
|
206
|
+
self._running = False
|
|
207
|
+
self._thread: threading.Thread | None = None
|
|
208
|
+
self._heartbeat_thread: threading.Thread | None = None
|
|
209
|
+
|
|
210
|
+
def wait_ended(self, timeout: float | None = None) -> bool:
|
|
211
|
+
"""Block until the reader has exited, i.e. the session is over."""
|
|
212
|
+
return self._ended.wait(timeout)
|
|
213
|
+
|
|
214
|
+
def _notify_disconnect(self, message: str) -> None:
|
|
215
|
+
# One call for both the message and the lifecycle: the UI
|
|
216
|
+
# learns the session ended, and the session screen can
|
|
217
|
+
# return to the Create/Join screen.
|
|
218
|
+
if self.on_error is not None:
|
|
219
|
+
self.on_error(message)
|
|
220
|
+
if self.on_disconnect is not None:
|
|
221
|
+
self.on_disconnect()
|
|
222
|
+
|
|
223
|
+
# -- sending -------------------------------------------------
|
|
224
|
+
def send_file(self, path: Path, on_progress=None) -> bool:
|
|
225
|
+
path = Path(path)
|
|
226
|
+
if not path.is_file():
|
|
227
|
+
return False
|
|
228
|
+
size = path.stat().st_size
|
|
229
|
+
digest = hashlib.sha256()
|
|
230
|
+
with open(path, "rb") as handle:
|
|
231
|
+
for chunk in iter(lambda: handle.read(1024 * 256), b""):
|
|
232
|
+
digest.update(chunk)
|
|
233
|
+
header = {"type": "file", "name": path.name, "size": size, "sha256": digest.hexdigest()}
|
|
234
|
+
with self._send_lock:
|
|
235
|
+
try:
|
|
236
|
+
_send_header(self.sock, header)
|
|
237
|
+
sent = 0
|
|
238
|
+
last_report = 0.0
|
|
239
|
+
with open(path, "rb") as handle:
|
|
240
|
+
while True:
|
|
241
|
+
chunk = handle.read(1024 * 128)
|
|
242
|
+
if not chunk:
|
|
243
|
+
break
|
|
244
|
+
self.sock.sendall(chunk)
|
|
245
|
+
sent += len(chunk)
|
|
246
|
+
# Sender progress was the bug: it reported every
|
|
247
|
+
# 128KB chunk, while the receiver reports at most
|
|
248
|
+
# every 0.1s. The UI computes speed from the time
|
|
249
|
+
# between UI callbacks, and call_from_thread lets
|
|
250
|
+
# the sender run ahead and queue a burst of
|
|
251
|
+
# callbacks that the UI then processes almost
|
|
252
|
+
# instantly, so bytes / tiny time = 4-5x speed.
|
|
253
|
+
# Throttle the sender exactly like the receiver:
|
|
254
|
+
# at most every 0.1s, plus always on the final
|
|
255
|
+
# chunk, so the UI time between reports is real
|
|
256
|
+
# network time, not queue-processing time.
|
|
257
|
+
now = time.monotonic()
|
|
258
|
+
if on_progress is not None and (now - last_report >= 0.1 or sent >= size):
|
|
259
|
+
last_report = now
|
|
260
|
+
on_progress(path.name, sent, size)
|
|
261
|
+
except Exception as exc:
|
|
262
|
+
if self.on_error is not None:
|
|
263
|
+
self.on_error(f"send failed: {exc}")
|
|
264
|
+
return False
|
|
265
|
+
return True
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def request_download(self, name: str, request_id: str = "") -> bool:
|
|
269
|
+
"""Ask the peer for approval to download a file.
|
|
270
|
+
|
|
271
|
+
``name`` may be absolute (a path on the peer) or relative
|
|
272
|
+
to the peer's cwd. Nothing is sent until the peer allows
|
|
273
|
+
it; the file then arrives as a normal file message."""
|
|
274
|
+
with self._send_lock:
|
|
275
|
+
try:
|
|
276
|
+
_send_header(self.sock, {"type": "download_ask", "name": name, "id": request_id})
|
|
277
|
+
except Exception as exc:
|
|
278
|
+
if self.on_error is not None:
|
|
279
|
+
self.on_error(f"download request failed: {exc}")
|
|
280
|
+
return False
|
|
281
|
+
return True
|
|
282
|
+
|
|
283
|
+
def respond_download(self, name: str, request_id: str, allow: bool, on_progress=None) -> bool:
|
|
284
|
+
"""Peer side: answer a download_ask. On allow, send the
|
|
285
|
+
file (absolute path, or relative to this side's cwd).
|
|
286
|
+
On deny, tell the requester, and send nothing.
|
|
287
|
+
on_progress, when given, is passed to send_file so the
|
|
288
|
+
approver side gets progress like a normal send. Without
|
|
289
|
+
it, the approver saw no progress bar at all."""
|
|
290
|
+
if not allow:
|
|
291
|
+
with self._send_lock:
|
|
292
|
+
try:
|
|
293
|
+
_send_header(self.sock, {"type": "download_denied", "name": name, "id": request_id})
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
296
|
+
return True
|
|
297
|
+
path = Path(name).expanduser()
|
|
298
|
+
if not path.is_absolute():
|
|
299
|
+
path = self.cwd / path
|
|
300
|
+
# send_file only sends files, and its header carries only
|
|
301
|
+
# the basename, so the receiver saves it in its own cwd.
|
|
302
|
+
return self.send_file(path, on_progress=on_progress)
|
|
303
|
+
|
|
304
|
+
def send_ack(self, name: str, ok: bool, reason: str = "") -> None:
|
|
305
|
+
with self._send_lock:
|
|
306
|
+
try:
|
|
307
|
+
_send_header(self.sock, {"type": "ack", "name": name, "ok": ok, "reason": reason})
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# -- receiving ------------------------------------------------
|
|
314
|
+
def start(self) -> None:
|
|
315
|
+
if self._running:
|
|
316
|
+
return
|
|
317
|
+
_set_keepalive(self.sock)
|
|
318
|
+
self._running = True
|
|
319
|
+
self._thread = threading.Thread(target=self._reader, daemon=True)
|
|
320
|
+
self._thread.start()
|
|
321
|
+
self._heartbeat_thread = threading.Thread(target=self._heartbeat, daemon=True)
|
|
322
|
+
self._heartbeat_thread.start()
|
|
323
|
+
|
|
324
|
+
def _heartbeat(self) -> None:
|
|
325
|
+
"""Ping every 10 seconds while idle, so NAT/firewall idle
|
|
326
|
+
timeouts do not close a session with no file transferring.
|
|
327
|
+
Ping/pong are control messages, not file bytes."""
|
|
328
|
+
while self._running:
|
|
329
|
+
time.sleep(10)
|
|
330
|
+
if not self._running:
|
|
331
|
+
break
|
|
332
|
+
try:
|
|
333
|
+
with self._send_lock:
|
|
334
|
+
_send_header(self.sock, {"type": "ping"})
|
|
335
|
+
except Exception:
|
|
336
|
+
break
|
|
337
|
+
|
|
338
|
+
def _reader(self) -> None:
|
|
339
|
+
try:
|
|
340
|
+
while self._running:
|
|
341
|
+
raw_len = _recv_exact(self.sock, 4)
|
|
342
|
+
(length,) = struct.unpack(">I", raw_len)
|
|
343
|
+
if length <= 0 or length > 1024 * 1024:
|
|
344
|
+
raise ConnectionError("bad header length")
|
|
345
|
+
header = json.loads(_recv_exact(self.sock, length).decode())
|
|
346
|
+
kind = header.get("type")
|
|
347
|
+
if kind == "file":
|
|
348
|
+
self._receive_file(header)
|
|
349
|
+
elif kind == "download_ask":
|
|
350
|
+
if self.on_download_ask is not None:
|
|
351
|
+
self.on_download_ask(str(header.get("name", "")), str(header.get("id", "")))
|
|
352
|
+
elif kind == "download_denied":
|
|
353
|
+
if self.on_download_denied is not None:
|
|
354
|
+
self.on_download_denied(str(header.get("name", "")), str(header.get("id", "")))
|
|
355
|
+
elif kind == "ack":
|
|
356
|
+
if self.on_ack is not None:
|
|
357
|
+
self.on_ack(header.get("name", ""), bool(header.get("ok")), header.get("reason", ""))
|
|
358
|
+
elif kind == "ping":
|
|
359
|
+
# Reply, so both directions see traffic. No UI update.
|
|
360
|
+
try:
|
|
361
|
+
with self._send_lock:
|
|
362
|
+
_send_header(self.sock, {"type": "pong"})
|
|
363
|
+
except Exception:
|
|
364
|
+
pass
|
|
365
|
+
elif kind == "pong":
|
|
366
|
+
pass
|
|
367
|
+
elif kind == "bye":
|
|
368
|
+
# The peer disconnected cleanly. Tell the UI,
|
|
369
|
+
# exactly like a failed read does, otherwise
|
|
370
|
+
# the session dies silently and the screen
|
|
371
|
+
# keeps showing connected.
|
|
372
|
+
self._notify_disconnect("peer disconnected")
|
|
373
|
+
break
|
|
374
|
+
elif kind == "error":
|
|
375
|
+
if self.on_error is not None:
|
|
376
|
+
self.on_error(str(header.get("message", "")))
|
|
377
|
+
except Exception as exc:
|
|
378
|
+
if self._running:
|
|
379
|
+
self._notify_disconnect(f"session ended: {exc}")
|
|
380
|
+
finally:
|
|
381
|
+
self._running = False
|
|
382
|
+
self._ended.set()
|
|
383
|
+
|
|
384
|
+
def _receive_file(self, header: dict) -> None:
|
|
385
|
+
name = Path(str(header.get("name", ""))).name
|
|
386
|
+
size = int(header.get("size", 0))
|
|
387
|
+
expected = str(header.get("sha256", ""))
|
|
388
|
+
part = self.cwd / f"{name}.part"
|
|
389
|
+
final = self.cwd / name
|
|
390
|
+
digest = hashlib.sha256()
|
|
391
|
+
remaining = size
|
|
392
|
+
received = 0
|
|
393
|
+
last_report = 0.0
|
|
394
|
+
try:
|
|
395
|
+
with open(part, "wb") as dest:
|
|
396
|
+
while remaining > 0:
|
|
397
|
+
chunk = self.sock.recv(min(1024 * 128, remaining))
|
|
398
|
+
if not chunk:
|
|
399
|
+
raise ConnectionError("session closed during file")
|
|
400
|
+
dest.write(chunk)
|
|
401
|
+
digest.update(chunk)
|
|
402
|
+
remaining -= len(chunk)
|
|
403
|
+
received += len(chunk)
|
|
404
|
+
# Receiver progress: report at most every 0.1s, plus
|
|
405
|
+
# always on the final chunk, so the UI stays live
|
|
406
|
+
# without flooding it on every 128KB chunk.
|
|
407
|
+
now = time.monotonic()
|
|
408
|
+
if self.on_progress is not None and (now - last_report >= 0.1 or remaining == 0):
|
|
409
|
+
last_report = now
|
|
410
|
+
try:
|
|
411
|
+
self.on_progress(name, received, size)
|
|
412
|
+
except Exception:
|
|
413
|
+
pass
|
|
414
|
+
except Exception as exc:
|
|
415
|
+
if self.on_error is not None:
|
|
416
|
+
self.on_error(f"receive failed: {exc}")
|
|
417
|
+
if self.on_file_received is not None:
|
|
418
|
+
self.on_file_received(name, False, "incomplete")
|
|
419
|
+
return
|
|
420
|
+
ok = digest.hexdigest() == expected
|
|
421
|
+
saved_name = name
|
|
422
|
+
rename_reason = ""
|
|
423
|
+
if ok:
|
|
424
|
+
# Never silently overwrite. Pick a free name, re-check
|
|
425
|
+
# just before the rename, and if a file appeared in
|
|
426
|
+
# that window, pick the next free name. The old
|
|
427
|
+
# fallback that unlinked the target and replaced it
|
|
428
|
+
# is removed on purpose, it was the overwrite bug.
|
|
429
|
+
if not name or name in (".", ".."):
|
|
430
|
+
try:
|
|
431
|
+
part.replace(final)
|
|
432
|
+
saved_name = name
|
|
433
|
+
except Exception as exc:
|
|
434
|
+
ok = False
|
|
435
|
+
rename_reason = f"rename failed: {exc}"
|
|
436
|
+
if self.on_error is not None:
|
|
437
|
+
self.on_error(rename_reason)
|
|
438
|
+
else:
|
|
439
|
+
for _attempt in range(1001):
|
|
440
|
+
unique = unique_final_path(self.cwd, name)
|
|
441
|
+
if unique is None:
|
|
442
|
+
ok = False
|
|
443
|
+
rename_reason = "too many duplicates"
|
|
444
|
+
break
|
|
445
|
+
if unique.exists() or unique.is_symlink():
|
|
446
|
+
continue
|
|
447
|
+
try:
|
|
448
|
+
part.replace(unique)
|
|
449
|
+
saved_name = unique.name
|
|
450
|
+
break
|
|
451
|
+
except Exception as exc:
|
|
452
|
+
# If the target appeared between check and
|
|
453
|
+
# rename, try the next free name. Any other
|
|
454
|
+
# error is a real rename failure.
|
|
455
|
+
if unique.exists() or unique.is_symlink():
|
|
456
|
+
continue
|
|
457
|
+
ok = False
|
|
458
|
+
rename_reason = f"rename failed: {exc}"
|
|
459
|
+
if self.on_error is not None:
|
|
460
|
+
self.on_error(rename_reason)
|
|
461
|
+
break
|
|
462
|
+
else:
|
|
463
|
+
ok = False
|
|
464
|
+
rename_reason = "too many duplicates"
|
|
465
|
+
# On a rename failure, the .part file is kept as in
|
|
466
|
+
# v1.1. Section 5 changes .part cleanup on failed
|
|
467
|
+
# transfers, it is not bundled into this section.
|
|
468
|
+
if self.on_file_received is not None:
|
|
469
|
+
if ok:
|
|
470
|
+
# On rename, tell the UI the original name, so
|
|
471
|
+
# it can show "saved as X, original Y already
|
|
472
|
+
# existed" instead of just the saved name.
|
|
473
|
+
if saved_name != name:
|
|
474
|
+
self.on_file_received(saved_name, True, f"renamed from {name}")
|
|
475
|
+
else:
|
|
476
|
+
self.on_file_received(saved_name, True, "")
|
|
477
|
+
elif digest.hexdigest() != expected:
|
|
478
|
+
self.on_file_received(name, False, "SHA-256 mismatch, kept as .part")
|
|
479
|
+
else:
|
|
480
|
+
self.on_file_received(name, False, rename_reason or "rename failed")
|
|
481
|
+
# Ack is a normal message, not a file, so it cannot interleave
|
|
482
|
+
# with file bytes: file bytes are already fully read here.
|
|
483
|
+
# On success, the ack carries the actual saved name, so
|
|
484
|
+
# the sender learns that the file was renamed to (1), (2), etc.
|
|
485
|
+
if ok:
|
|
486
|
+
self.send_ack(saved_name, True, "")
|
|
487
|
+
elif digest.hexdigest() != expected:
|
|
488
|
+
self.send_ack(name, False, "SHA-256 mismatch")
|
|
489
|
+
else:
|
|
490
|
+
self.send_ack(name, False, rename_reason or "rename failed")
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def close(self) -> None:
|
|
494
|
+
self._running = False
|
|
495
|
+
try:
|
|
496
|
+
with self._send_lock:
|
|
497
|
+
_send_header(self.sock, {"type": "bye"})
|
|
498
|
+
except Exception:
|
|
499
|
+
pass
|
|
500
|
+
try:
|
|
501
|
+
self.sock.close()
|
|
502
|
+
except Exception:
|
|
503
|
+
pass
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _read_header(sock) -> dict:
|
|
507
|
+
raw_len = _recv_exact(sock, 4)
|
|
508
|
+
(length,) = struct.unpack(">I", raw_len)
|
|
509
|
+
if length <= 0 or length > 1024 * 1024:
|
|
510
|
+
raise ConnectionError("bad header length")
|
|
511
|
+
return json.loads(_recv_exact(sock, length).decode())
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _server_context(key_path: Path, cert_path: Path) -> ssl.SSLContext:
|
|
519
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
520
|
+
ctx.load_cert_chain(str(cert_path), str(key_path))
|
|
521
|
+
# CERT_NONE here, because a self-signed client cert would fail
|
|
522
|
+
# Python's built-in verification even with CERT_OPTIONAL, as
|
|
523
|
+
# verified in the v1.1 spike. Client authentication does not
|
|
524
|
+
# rely on TLS client certificates at all. It relies on the
|
|
525
|
+
# signed hello challenge in HostListener.wait_for_client:
|
|
526
|
+
# the client presents its certificate in hello, signs a
|
|
527
|
+
# host-chosen nonce with its private key, and the host
|
|
528
|
+
# verifies that signature against the presented certificate.
|
|
529
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
530
|
+
ctx.check_hostname = False
|
|
531
|
+
return ctx
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _client_context(key_path: Path, cert_path: Path) -> ssl.SSLContext:
|
|
535
|
+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
536
|
+
ctx.load_cert_chain(str(cert_path), str(key_path))
|
|
537
|
+
ctx.check_hostname = False
|
|
538
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
539
|
+
return ctx
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _approve_arity(approve) -> int:
|
|
543
|
+
"""How many positional arguments an approve callback accepts.
|
|
544
|
+
|
|
545
|
+
Old callbacks take (device, fingerprint). New callbacks take
|
|
546
|
+
(device, fingerprint, status, old_fingerprint). Callbacks
|
|
547
|
+
with *args accept any count, so treat them as new.
|
|
548
|
+
"""
|
|
549
|
+
try:
|
|
550
|
+
params = inspect.signature(approve).parameters.values()
|
|
551
|
+
except Exception:
|
|
552
|
+
return 2
|
|
553
|
+
for param in params:
|
|
554
|
+
if param.kind == inspect.Parameter.VAR_POSITIONAL:
|
|
555
|
+
return 4
|
|
556
|
+
count = sum(1 for p in params if p.kind in (
|
|
557
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
558
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
559
|
+
))
|
|
560
|
+
return count
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _call_approve(approve, device: str, fingerprint: str, status: str, old_fingerprint: str) -> bool:
|
|
564
|
+
arity = _approve_arity(approve)
|
|
565
|
+
if arity >= 4:
|
|
566
|
+
return bool(approve(device, fingerprint, status, old_fingerprint))
|
|
567
|
+
if arity == 3:
|
|
568
|
+
return bool(approve(device, fingerprint, status))
|
|
569
|
+
return bool(approve(device, fingerprint))
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
class HostListener:
|
|
573
|
+
"""Host side: listen for one client, approve it in the UI, return a Session.
|
|
574
|
+
|
|
575
|
+
approve is called in the listener thread; the UI must answer
|
|
576
|
+
it (in tests it can auto-approve). Old callbacks take
|
|
577
|
+
(device, fingerprint). New callbacks take
|
|
578
|
+
(device, fingerprint, status, old_fingerprint), where status
|
|
579
|
+
is known, new, or changed. A changed fingerprint is only
|
|
580
|
+
passed to a new callback. An old callback never sees a
|
|
581
|
+
changed fingerprint, because that case aborts before approval,
|
|
582
|
+
so a plain Allow can never silently replace a saved fingerprint.
|
|
583
|
+
"""
|
|
584
|
+
|
|
585
|
+
def __init__(self, port: int, cwd: Path, device: str, home: Path | None = None,
|
|
586
|
+
approve=None):
|
|
587
|
+
self.port = port
|
|
588
|
+
self.cwd = Path(cwd)
|
|
589
|
+
self.device = device
|
|
590
|
+
self.home = home
|
|
591
|
+
self.approve = approve
|
|
592
|
+
self._server_socks: list[socket.socket] = []
|
|
593
|
+
|
|
594
|
+
def _abort(self, tls, message: str):
|
|
595
|
+
try:
|
|
596
|
+
_send_header(tls, {"type": "error", "message": message})
|
|
597
|
+
except Exception:
|
|
598
|
+
pass
|
|
599
|
+
try:
|
|
600
|
+
tls.close()
|
|
601
|
+
except Exception:
|
|
602
|
+
pass
|
|
603
|
+
raise ConnectionError(message)
|
|
604
|
+
|
|
605
|
+
def _create_listener_sockets(self, port: int) -> list[socket.socket]:
|
|
606
|
+
"""Listening sockets covering IPv4 and IPv6 on `port`.
|
|
607
|
+
|
|
608
|
+
Preferred: one dual-stack AF_INET6 socket (IPV6_V6ONLY=0)
|
|
609
|
+
that accepts both families on the same port, so an IPv6
|
|
610
|
+
client dialling the host's global address reaches us. If
|
|
611
|
+
the host refuses a dual-stack socket but still has IPv6,
|
|
612
|
+
one socket per family instead. If the host has no IPv6 at
|
|
613
|
+
all, a single IPv4 socket, exactly as before.
|
|
614
|
+
"""
|
|
615
|
+
try:
|
|
616
|
+
server = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
|
|
617
|
+
try:
|
|
618
|
+
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
619
|
+
server.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
|
|
620
|
+
server.bind(("::", port))
|
|
621
|
+
server.listen(1)
|
|
622
|
+
except Exception:
|
|
623
|
+
server.close()
|
|
624
|
+
raise
|
|
625
|
+
return [server]
|
|
626
|
+
except Exception:
|
|
627
|
+
pass
|
|
628
|
+
sockets: list[socket.socket] = []
|
|
629
|
+
last_exc: Exception | None = None
|
|
630
|
+
for family, addr in ((socket.AF_INET, "0.0.0.0"), (socket.AF_INET6, "::")):
|
|
631
|
+
try:
|
|
632
|
+
sock = socket.socket(family, socket.SOCK_STREAM)
|
|
633
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
634
|
+
if family == socket.AF_INET6:
|
|
635
|
+
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
|
|
636
|
+
sock.bind((addr, port))
|
|
637
|
+
sock.listen(1)
|
|
638
|
+
sockets.append(sock)
|
|
639
|
+
except Exception as exc: # noqa: BLE001 - retried per family, raised below
|
|
640
|
+
last_exc = exc
|
|
641
|
+
if not sockets:
|
|
642
|
+
raise OSError(f"could not bind a listener on port {port}") from last_exc
|
|
643
|
+
return sockets
|
|
644
|
+
|
|
645
|
+
def _accept_one(self, servers: list[socket.socket], timeout: float | None) -> tuple:
|
|
646
|
+
"""Accept one connection from any of `servers`, honouring `timeout`.
|
|
647
|
+
|
|
648
|
+
select() waits on all of them at once. stop() shutting the
|
|
649
|
+
sockets down wakes it (select raises, or accept raises on a
|
|
650
|
+
stopped socket), so leaving the Create screen still releases
|
|
651
|
+
the port at once, as it did for the single-socket accept().
|
|
652
|
+
"""
|
|
653
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
654
|
+
candidates = list(servers)
|
|
655
|
+
while candidates:
|
|
656
|
+
remaining = None
|
|
657
|
+
if deadline is not None:
|
|
658
|
+
remaining = deadline - time.monotonic()
|
|
659
|
+
if remaining <= 0:
|
|
660
|
+
raise socket.timeout("timed out")
|
|
661
|
+
readable, _, _ = select.select(candidates, [], [], remaining)
|
|
662
|
+
if not readable:
|
|
663
|
+
raise socket.timeout("timed out")
|
|
664
|
+
for server in readable:
|
|
665
|
+
try:
|
|
666
|
+
return server.accept()
|
|
667
|
+
except OSError:
|
|
668
|
+
# A stopped socket: drop it and keep waiting on
|
|
669
|
+
# the rest; if none are left, the listener is gone.
|
|
670
|
+
candidates.remove(server)
|
|
671
|
+
raise OSError("listener stopped")
|
|
672
|
+
|
|
673
|
+
def wait_for_client(self, timeout: float | None = None) -> Session:
|
|
674
|
+
key_path, cert_path = ensure_identity(self.home)
|
|
675
|
+
ctx = _server_context(key_path, cert_path)
|
|
676
|
+
servers = self._create_listener_sockets(self.port)
|
|
677
|
+
self._server_socks = servers
|
|
678
|
+
try:
|
|
679
|
+
raw, _addr = self._accept_one(servers, timeout)
|
|
680
|
+
finally:
|
|
681
|
+
for server in servers:
|
|
682
|
+
try:
|
|
683
|
+
server.close()
|
|
684
|
+
except Exception:
|
|
685
|
+
pass
|
|
686
|
+
self._server_socks = []
|
|
687
|
+
try:
|
|
688
|
+
tls = ctx.wrap_socket(raw, server_side=True)
|
|
689
|
+
except Exception as exc:
|
|
690
|
+
raise ConnectionError(f"TLS failed: {exc}") from exc
|
|
691
|
+
_set_keepalive(tls)
|
|
692
|
+
# Signed hello challenge. The client must present its
|
|
693
|
+
# certificate in hello, then sign a host-chosen nonce.
|
|
694
|
+
# A client that presents no certificate, or a bad
|
|
695
|
+
# signature, aborts here, before any approval prompt.
|
|
696
|
+
# This is a breaking change from v1.0 on purpose: a v1.0
|
|
697
|
+
# client cannot be authenticated, so a v1.1 host must
|
|
698
|
+
# not accept it on device name alone.
|
|
699
|
+
try:
|
|
700
|
+
hello = _read_header(tls)
|
|
701
|
+
except Exception as exc:
|
|
702
|
+
try:
|
|
703
|
+
tls.close()
|
|
704
|
+
except Exception:
|
|
705
|
+
pass
|
|
706
|
+
raise ConnectionError(f"hello failed: {exc}") from exc
|
|
707
|
+
client_device = str(hello.get("device", "")) or "client"
|
|
708
|
+
# On a dual-stack socket an IPv4 peer arrives as an
|
|
709
|
+
# IPv4-mapped IPv6 address (::ffff:192.168.1.6); unwrap it
|
|
710
|
+
# so the saved peer address stays a plain IPv4 address.
|
|
711
|
+
peer_ip = _addr[0]
|
|
712
|
+
if peer_ip.startswith("::ffff:"):
|
|
713
|
+
peer_ip = peer_ip[7:]
|
|
714
|
+
dialled_host = str(hello.get("host", "")) or peer_ip
|
|
715
|
+
cert_b64 = str(hello.get("cert", "") or "")
|
|
716
|
+
if not cert_b64:
|
|
717
|
+
self._abort(tls, "client fingerprint missing, cannot authenticate")
|
|
718
|
+
try:
|
|
719
|
+
cert_der = base64.b64decode(cert_b64, validate=True)
|
|
720
|
+
if not cert_der:
|
|
721
|
+
raise ValueError("empty certificate")
|
|
722
|
+
peer_fp = cert_fingerprint(cert_der)
|
|
723
|
+
except Exception:
|
|
724
|
+
self._abort(tls, "client certificate invalid")
|
|
725
|
+
nonce = os.urandom(32)
|
|
726
|
+
try:
|
|
727
|
+
_send_header(tls, {"type": "hello_challenge", "nonce": base64.b64encode(nonce).decode()})
|
|
728
|
+
proof = _read_header(tls)
|
|
729
|
+
except Exception as exc:
|
|
730
|
+
try:
|
|
731
|
+
tls.close()
|
|
732
|
+
except Exception:
|
|
733
|
+
pass
|
|
734
|
+
raise ConnectionError(f"hello challenge failed: {exc}") from exc
|
|
735
|
+
if proof.get("type") != "hello_proof":
|
|
736
|
+
self._abort(tls, "client signature verification failed")
|
|
737
|
+
try:
|
|
738
|
+
signature = base64.b64decode(str(proof.get("signature", "") or ""), validate=True)
|
|
739
|
+
except Exception:
|
|
740
|
+
self._abort(tls, "client signature verification failed")
|
|
741
|
+
if not verify_nonce_signature(cert_der, nonce, signature):
|
|
742
|
+
self._abort(tls, "client signature verification failed")
|
|
743
|
+
# Trust status, from trusted clients saved by this host.
|
|
744
|
+
try:
|
|
745
|
+
config = load_config(self.home)
|
|
746
|
+
except Exception:
|
|
747
|
+
config = None
|
|
748
|
+
status = "new"
|
|
749
|
+
old_fp = ""
|
|
750
|
+
if config is not None:
|
|
751
|
+
if config.trusted_client_for_fingerprint(peer_fp) is not None:
|
|
752
|
+
status = "known"
|
|
753
|
+
else:
|
|
754
|
+
by_device = config.trusted_client_for_device(client_device)
|
|
755
|
+
if by_device is not None:
|
|
756
|
+
status = "changed"
|
|
757
|
+
old_fp = by_device.fingerprint
|
|
758
|
+
if status == "changed" and (self.approve is None or _approve_arity(self.approve) < 3):
|
|
759
|
+
self._abort(tls, "fingerprint changed, session aborted")
|
|
760
|
+
if self.approve is not None and not _call_approve(self.approve, client_device, peer_fp, status, old_fp):
|
|
761
|
+
try:
|
|
762
|
+
tls.close()
|
|
763
|
+
except Exception:
|
|
764
|
+
pass
|
|
765
|
+
raise ConnectionError("host denied the session")
|
|
766
|
+
# Save the trusted client only after approval. Known
|
|
767
|
+
# needs no save. New and changed, where the user pressed
|
|
768
|
+
# Accept after seeing the warning, are saved here, so a
|
|
769
|
+
# later connection is known, or the replaced fingerprint
|
|
770
|
+
# is the new trusted one.
|
|
771
|
+
if config is not None and status in ("new", "changed"):
|
|
772
|
+
try:
|
|
773
|
+
from .config import Peer
|
|
774
|
+
|
|
775
|
+
add_trusted_client(config, Peer(name=client_device, host="", fingerprint=peer_fp), home=self.home)
|
|
776
|
+
except Exception as exc:
|
|
777
|
+
try:
|
|
778
|
+
tls.close()
|
|
779
|
+
except Exception:
|
|
780
|
+
pass
|
|
781
|
+
raise ConnectionError(f"failed to save trusted client: {exc}") from exc
|
|
782
|
+
try:
|
|
783
|
+
_send_header(tls, {"type": "hello_ok", "device": self.device})
|
|
784
|
+
except Exception as exc:
|
|
785
|
+
raise ConnectionError(f"hello failed: {exc}") from exc
|
|
786
|
+
session = Session(tls, self.cwd, device=self.device, peer_device=client_device, peer_host=dialled_host)
|
|
787
|
+
session.peer_fingerprint = peer_fp
|
|
788
|
+
session.client_trust_status = status
|
|
789
|
+
session.client_old_fingerprint = old_fp
|
|
790
|
+
return session
|
|
791
|
+
|
|
792
|
+
def stop(self) -> None:
|
|
793
|
+
# shutdown() first: close() alone does not wake a thread
|
|
794
|
+
# blocked in accept() or select(), so the worker would stay
|
|
795
|
+
# blocked and the port stayed bound, and the next Create
|
|
796
|
+
# Server page in the same process failed to bind with
|
|
797
|
+
# EADDRINUSE. shutdown() wakes both at once, and close()
|
|
798
|
+
# then frees the socket.
|
|
799
|
+
for server in list(self._server_socks):
|
|
800
|
+
try:
|
|
801
|
+
server.shutdown(socket.SHUT_RDWR)
|
|
802
|
+
except Exception:
|
|
803
|
+
pass
|
|
804
|
+
try:
|
|
805
|
+
server.close()
|
|
806
|
+
except Exception:
|
|
807
|
+
pass
|
|
808
|
+
self._server_socks = []
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def join_session(host: str, port: int, cwd: Path, device: str,
|
|
812
|
+
known_fingerprint: str = "", home: Path | None = None,
|
|
813
|
+
approve_fingerprint=None) -> Session:
|
|
814
|
+
"""Client side: dial the host, check its cert fingerprint, return a Session.
|
|
815
|
+
|
|
816
|
+
approve_fingerprint(fingerprint) -> bool is called when the
|
|
817
|
+
fingerprint is unknown or changed; in the UI it is the Allow button.
|
|
818
|
+
|
|
819
|
+
After the host fingerprint check, the client proves its own
|
|
820
|
+
identity to a v1.1 host with a signed hello challenge. A v1.0
|
|
821
|
+
host replies hello_ok directly, without a challenge, and that
|
|
822
|
+
path still works, because the client side check is unchanged
|
|
823
|
+
and the old host simply never learns the client fingerprint.
|
|
824
|
+
"""
|
|
825
|
+
key_path, cert_path = ensure_identity(home)
|
|
826
|
+
ctx = _client_context(key_path, cert_path)
|
|
827
|
+
raw = socket.create_connection((host, port), timeout=30)
|
|
828
|
+
try:
|
|
829
|
+
tls = ctx.wrap_socket(raw, server_hostname=host)
|
|
830
|
+
except Exception as exc:
|
|
831
|
+
try:
|
|
832
|
+
raw.close()
|
|
833
|
+
except Exception:
|
|
834
|
+
pass
|
|
835
|
+
raise ConnectionError(f"TLS failed: {exc}") from exc
|
|
836
|
+
# Critical: create_connection left a 30s timeout on this socket.
|
|
837
|
+
# Without clearing it, an idle session timed out after 30s.
|
|
838
|
+
_set_keepalive(tls)
|
|
839
|
+
server_der = tls.getpeercert(binary_form=True)
|
|
840
|
+
server_fp = cert_fingerprint(server_der) if server_der else ""
|
|
841
|
+
if known_fingerprint and server_fp != known_fingerprint:
|
|
842
|
+
try:
|
|
843
|
+
tls.close()
|
|
844
|
+
except Exception:
|
|
845
|
+
pass
|
|
846
|
+
raise ConnectionError("fingerprint changed, session aborted")
|
|
847
|
+
if not known_fingerprint and approve_fingerprint is not None:
|
|
848
|
+
if not approve_fingerprint(server_fp):
|
|
849
|
+
try:
|
|
850
|
+
tls.close()
|
|
851
|
+
except Exception:
|
|
852
|
+
pass
|
|
853
|
+
raise ConnectionError("client denied the fingerprint")
|
|
854
|
+
try:
|
|
855
|
+
try:
|
|
856
|
+
cert_b64 = base64.b64encode(cert_der_from_pem(cert_path)).decode()
|
|
857
|
+
except Exception:
|
|
858
|
+
cert_b64 = ""
|
|
859
|
+
_send_header(tls, {"type": "hello", "device": device, "host": host, "cert": cert_b64})
|
|
860
|
+
reply = _read_header(tls)
|
|
861
|
+
if reply.get("type") == "hello_ok":
|
|
862
|
+
hello_ok = reply
|
|
863
|
+
elif reply.get("type") == "error":
|
|
864
|
+
raise ConnectionError(f"host rejected: {reply.get('message', 'session rejected')}")
|
|
865
|
+
elif reply.get("type") != "hello_challenge":
|
|
866
|
+
raise ConnectionError("host did not accept the session")
|
|
867
|
+
else:
|
|
868
|
+
try:
|
|
869
|
+
nonce = base64.b64decode(str(reply.get("nonce", "") or ""), validate=True)
|
|
870
|
+
signature = sign_nonce(key_path, nonce)
|
|
871
|
+
except Exception as exc:
|
|
872
|
+
raise ConnectionError(f"hello proof failed: {exc}") from exc
|
|
873
|
+
_send_header(tls, {"type": "hello_proof", "signature": base64.b64encode(signature).decode()})
|
|
874
|
+
hello_ok = _read_header(tls)
|
|
875
|
+
if hello_ok.get("type") == "error":
|
|
876
|
+
raise ConnectionError(f"host rejected: {hello_ok.get('message', 'session rejected')}")
|
|
877
|
+
if hello_ok.get("type") != "hello_ok":
|
|
878
|
+
raise ConnectionError("host did not accept the session")
|
|
879
|
+
except Exception:
|
|
880
|
+
try:
|
|
881
|
+
tls.close()
|
|
882
|
+
except Exception:
|
|
883
|
+
pass
|
|
884
|
+
raise
|
|
885
|
+
session = Session(tls, cwd, device=device, peer_device=str(hello_ok.get("device", "")), peer_host=host)
|
|
886
|
+
session.peer_fingerprint = server_fp
|
|
887
|
+
return session
|