ccssh 1.0.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.
ccssh/stub.py ADDED
@@ -0,0 +1,710 @@
1
+ """
2
+ In-process SSH server stub for loopback tests. NOT production code.
3
+
4
+ Speaks just enough of the protocol to exercise a real client over TCP:
5
+ version exchange, KEXINIT (curve25519-sha256 / mlkem768x25519-sha256),
6
+ NEWKEYS with aes128-ctr + hmac-sha2-256, strict-kex/ext-info, password and
7
+ publickey auth (with strict signature checks, like a real sshd), session
8
+ exec (canned replies), SFTP subsystem (confined temp dir), direct-tcpip
9
+ forwarding relay, and rekey between operations. Never executes commands.
10
+ """
11
+ from __future__ import annotations
12
+ import os
13
+ import socket
14
+ import stat as statmod
15
+ import tempfile
16
+ import threading
17
+
18
+ from ccssh import auth as A
19
+ from ccssh import channel as CH
20
+ from ccssh import crypto
21
+ from ccssh import kex as K
22
+ from ccssh import wire as W
23
+ from ccssh.transport import SyncTransport
24
+
25
+ try:
26
+ from ccssh import __version__ as _VERSION
27
+ except Exception:
28
+ _VERSION = "1.0.0"
29
+ SERVER_VERSION = ("SSH-2.0-CC_" + _VERSION).encode()
30
+ CIPHER = "aes128-ctr"
31
+ MAC = "hmac-sha2-256"
32
+
33
+
34
+ def _is_composite_host(host_priv) -> bool:
35
+ return type(host_priv).__name__ == "KeyPair"
36
+
37
+
38
+ def _verify_user_sig(alg: bytes, blob: bytes, packet: bytes, to_verify: bytes) -> bool:
39
+ """Strict userauth signature check, mirroring a real sshd."""
40
+ from cryptography.exceptions import InvalidSignature
41
+ r = W.Reader(packet)
42
+ r.byte()
43
+ r.string()
44
+ r.string()
45
+ r.string()
46
+ r.bool()
47
+ r.string()
48
+ r.string()
49
+ try:
50
+ sig_field = r.string()
51
+ except Exception:
52
+ return False
53
+ try:
54
+ from ccssh import hostkeys as _HK
55
+ return _HK.verify_host_signature(blob, alg.decode(), to_verify, sig_field)
56
+ except (InvalidSignature, ValueError):
57
+ return False
58
+
59
+
60
+ def _send_kexinit(t: SyncTransport, *, hostkeys: list[str] | None = None, initial: bool = True) -> bytes:
61
+ kex = list(K.KEX_OFFER)
62
+ if initial:
63
+ kex += [K.KEX_STRICT_S]
64
+ pkt = K.enc_kexinit(os.urandom(16), kex, hostkeys or K.HOSTKEY_OFFER, K.CIPHER_OFFER, K.CIPHER_OFFER, K.MAC_OFFER, K.MAC_OFFER, K.COMP_OFFER, K.COMP_OFFER)
65
+ t.send_packet(pkt)
66
+ return pkt
67
+
68
+
69
+ def _recv_kexinit(t: SyncTransport) -> dict:
70
+ pkt = t.recv_packet()
71
+ if not pkt or pkt[0] != K.MSG_KEXINIT:
72
+ raise ValueError(f"expected KEXINIT, got msg={pkt[0] if pkt else None}")
73
+ return K.dec_kexinit(pkt)
74
+
75
+
76
+ def _send_newkeys(t: SyncTransport) -> None:
77
+ t.send_packet(bytes([K.MSG_NEWKEYS]))
78
+ t._enc_on_send = True
79
+
80
+
81
+ def _recv_newkeys(t: SyncTransport) -> None:
82
+ pkt = t.recv_packet()
83
+ if not pkt or pkt[0] != K.MSG_NEWKEYS:
84
+ raise ValueError("expected NEWKEYS")
85
+ t._enc_on_recv = True
86
+
87
+
88
+ def _do_kex(t: SyncTransport, host_priv, Vc: bytes, Vs: bytes, peer_kex: dict, our_kex: bytes) -> tuple[bytes, bytes]:
89
+ """Run one key exchange; returns (K_encoded, H). No key activation."""
90
+ kex_name = K.negotiate(K.KEX_OFFER, peer_kex["kex"])
91
+ Ic_H, Is_H = K.I_FOR_H(peer_kex["payload_full"]), K.I_FOR_H(our_kex)
92
+ init_msg = t.recv_packet()
93
+ if kex_name == "curve25519-sha256":
94
+ srv = K.Curve25519ServerStub(host_priv)
95
+ reply, K_ecdh, Hh = srv.reply(init_msg, Vc, Vs, Ic_H, Is_H)
96
+ K_encoded = W.mpint_from_bytes(K_ecdh)
97
+ else:
98
+ srv = K.MlkemHybridServerStub(host_priv, Vs, Is_H)
99
+ reply, K_raw, Hh = srv.reply(init_msg, Vc, Ic_H)
100
+ K_encoded = W.enc_string(K_raw)
101
+ t.send_packet(reply)
102
+ return K_encoded, Hh
103
+
104
+
105
+ def run_once(sock: socket.socket, host_priv, *, password: str = "pw", timeout: float = 10.0, sftp_root: str | None = None, kbdint_password: str | None = None) -> None:
106
+ t = SyncTransport(sock, SERVER_VERSION, timeout=timeout)
107
+ Vs = SERVER_VERSION
108
+ Vc = t.do_version()
109
+ peer_kex = _recv_kexinit(t)
110
+ hk_offer = (["ssh-mldsa44-ed25519"] if _is_composite_host(host_priv) else None)
111
+ our_kex = _send_kexinit(t, hostkeys=hk_offer)
112
+ K_encoded, Hh = _do_kex(t, host_priv, Vc, Vs, peer_kex, our_kex)
113
+ session_id = Hh
114
+ keys = crypto.derive_keys(K_encoded, Hh, session_id, CIPHER, MAC)
115
+ t.activate(keys, CIPHER, MAC, is_client=False)
116
+ strict = K.peer_wants_strict(peer_kex["kex"], is_client=False)
117
+ _send_newkeys(t)
118
+ _recv_newkeys(t)
119
+ if strict:
120
+ t.send_seq = 0
121
+ t.recv_seq = 0
122
+ if K.peer_wants_ext_info(peer_kex["kex"]):
123
+ t.send_packet(K.enc_ext_info([(b"server-sig-algs", b"ssh-ed25519,ssh-mldsa44-ed25519,rsa-sha2-256,rsa-sha2-512," b"ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521")]))
124
+ pkt = t.recv_packet()
125
+ assert pkt[0] == A.MSG_SERVICE_REQUEST
126
+ t.send_packet(bytes([A.MSG_SERVICE_ACCEPT]) + W.enc_string(b"ssh-userauth"))
127
+ _auth_loop(t, Hh, password, kbdint_password if kbdint_password is not None else password)
128
+ _connection_loop(t, Vc, Vs, host_priv, session_id, timeout, sftp_root)
129
+
130
+
131
+ def _auth_loop(t: SyncTransport, Hh: bytes, password: str, kbdint_password: str | None = None) -> None:
132
+ if kbdint_password is None:
133
+ kbdint_password = password
134
+ authed = False
135
+ while not authed:
136
+ p = t.recv_packet()
137
+ assert p[0] == A.MSG_USERAUTH_REQUEST
138
+ d = A.dec_userauth_request(p)
139
+ if d["method"] == "keyboard-interactive":
140
+ # Minimal RFC 4256 server: a single non-echo password prompt.
141
+ t.send_packet(bytes([A.MSG_USERAUTH_INFO_REQUEST])
142
+ + W.enc_string(b"")
143
+ + W.enc_string(b"")
144
+ + W.enc_string(b"")
145
+ + W.enc_u32(1)
146
+ + W.enc_string(b"Password:")
147
+ + W.enc_bool(False))
148
+ p2 = t.recv_packet()
149
+ try:
150
+ answers = A.dec_info_response(p2)
151
+ except Exception:
152
+ answers = []
153
+ if len(answers) == 1 and answers[0].decode(errors="replace") == kbdint_password:
154
+ t.send_packet(bytes([A.MSG_USERAUTH_SUCCESS]))
155
+ authed = True
156
+ else:
157
+ t.send_packet(bytes([A.MSG_USERAUTH_FAILURE]) + W.enc_namelist(["keyboard-interactive"]) + W.enc_bool(False))
158
+ elif d["method"] == "password":
159
+ r = W.Reader(p)
160
+ r.byte()
161
+ r.string()
162
+ r.string()
163
+ r.string()
164
+ r.bool()
165
+ pw = r.string().decode()
166
+ if pw == password:
167
+ t.send_packet(bytes([A.MSG_USERAUTH_SUCCESS]))
168
+ authed = True
169
+ else:
170
+ t.send_packet(bytes([A.MSG_USERAUTH_FAILURE]) + W.enc_namelist(["password", "publickey"]) + W.enc_bool(False))
171
+ elif d["method"] == "publickey":
172
+ r = W.Reader(p)
173
+ r.byte()
174
+ r.string()
175
+ r.string()
176
+ r.string()
177
+ has_sig = r.bool()
178
+ alg = r.string()
179
+ blob = r.string()
180
+ if not has_sig:
181
+ t.send_packet(bytes([A.MSG_USERAUTH_PK_OK])
182
+ + W.enc_string(alg)
183
+ + W.enc_string(blob))
184
+ else:
185
+ to_verify = A.auth_sign_blob(Hh, d["user"], alg, blob)
186
+ if _verify_user_sig(alg, blob, p, to_verify):
187
+ t.send_packet(bytes([A.MSG_USERAUTH_SUCCESS]))
188
+ authed = True
189
+ else:
190
+ t.send_packet(bytes([A.MSG_USERAUTH_FAILURE]) + W.enc_namelist(["password", "publickey"]) + W.enc_bool(False))
191
+ else:
192
+ t.send_packet(bytes([A.MSG_USERAUTH_FAILURE]) + W.enc_namelist(["password", "publickey"]) + W.enc_bool(False))
193
+
194
+
195
+ def _connection_loop(t: SyncTransport, Vc: bytes, Vs: bytes, host_priv, session_id: bytes, timeout: float, sftp_root: str | None) -> None:
196
+ server_chan = 7
197
+ send_lock = threading.Lock()
198
+ fwd: dict[int, object] = {}
199
+ shells: set[int] = set() # recipient ids with an accepted shell (echo mode)
200
+ rfwd: dict[tuple[str, int], socket.socket] = {} # (bind, port) -> listener
201
+ rchan_tcp: dict[int, object] = {} # our channel id -> relayed TCP socket
202
+ rchan_peer: dict[int, int] = {} # our channel id -> client channel id
203
+ rfwd_pending: dict[int, object] = {} # our channel id -> TCP socket awaiting CONFIRM
204
+
205
+ def _send_locked(payload: bytes) -> None:
206
+ with send_lock:
207
+ t.send_packet(payload)
208
+
209
+ def _rchan_relay(ours: int, tcp) -> None:
210
+ try:
211
+ while True:
212
+ data = tcp.recv(32768)
213
+ if not data:
214
+ break
215
+ peer = rchan_peer.get(ours)
216
+ if peer is None:
217
+ break
218
+ _send_locked(bytes([CH.MSG_CHANNEL_DATA]) + W.enc_u32(peer) + W.enc_string(data))
219
+ except Exception:
220
+ pass
221
+ finally:
222
+ try:
223
+ tcp.close()
224
+ except Exception:
225
+ pass
226
+ peer = rchan_peer.pop(ours, None)
227
+ rchan_tcp.pop(ours, None)
228
+ rfwd_pending.pop(ours, None)
229
+ if peer is not None:
230
+ try:
231
+ _send_locked(bytes([CH.MSG_CHANNEL_CLOSE]) + W.enc_u32(peer))
232
+ except Exception:
233
+ pass
234
+
235
+ def _rfwd_accept(key: tuple[str, int]) -> None:
236
+ nonlocal server_chan
237
+ while key in rfwd:
238
+ ls = rfwd.get(key)
239
+ if ls is None:
240
+ return
241
+ try:
242
+ conn, addr = ls.accept()
243
+ except Exception:
244
+ return
245
+ with send_lock:
246
+ ours = server_chan
247
+ server_chan += 1
248
+ rfwd_pending[ours] = conn
249
+ t.send_packet(bytes([CH.MSG_CHANNEL_OPEN]) + W.enc_string(b"forwarded-tcpip") + W.enc_u32(ours) + W.enc_u32(CH.WINDOW) + W.enc_u32(CH.MAXPKT) + W.enc_string(key[0].encode()) + W.enc_u32(key[1]) + W.enc_string(addr[0].encode()) + W.enc_u32(addr[1]))
250
+
251
+ def _fwd_relay(cch: int, sch: int, tcp) -> None:
252
+ try:
253
+ while True:
254
+ data = tcp.recv(32768)
255
+ if not data:
256
+ break
257
+ _send_locked(bytes([CH.MSG_CHANNEL_DATA]) + W.enc_u32(cch) + W.enc_string(data))
258
+ except Exception:
259
+ pass
260
+ finally:
261
+ try:
262
+ tcp.close()
263
+ except Exception:
264
+ pass
265
+ fwd.pop(sch, None)
266
+ try:
267
+ _send_locked(bytes([CH.MSG_CHANNEL_CLOSE]) + W.enc_u32(cch))
268
+ except Exception:
269
+ pass
270
+
271
+ while True:
272
+ try:
273
+ p = t.recv_raw()
274
+ except Exception:
275
+ return
276
+ m = p[0]
277
+ if m in (2, 4): # IGNORE, DEBUG: skipped here (recv_raw has no service loop)
278
+ continue
279
+ if m == 1: # DISCONNECT
280
+ return
281
+ if m == 80: # GLOBAL_REQUEST: answered here (recv_raw, no auto-answer)
282
+ try:
283
+ r = W.Reader(p)
284
+ r.byte()
285
+ name, want = r.string(), r.bool()
286
+ except Exception:
287
+ continue
288
+ if name == b"tcpip-forward" and want:
289
+ try:
290
+ bind = r.string().decode() or "127.0.0.1"
291
+ sport = r.u32()
292
+ ls = socket.socket()
293
+ ls.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
294
+ ls.bind((bind if bind != "localhost" else "127.0.0.1", sport))
295
+ ls.listen(16)
296
+ real = ls.getsockname()[1]
297
+ rfwd[(bind, real)] = ls
298
+ threading.Thread(target=_rfwd_accept, args=((bind, real),), daemon=True).start()
299
+ t.send_packet(bytes([81]) + W.enc_u32(real))
300
+ except Exception:
301
+ try:
302
+ t.send_packet(bytes([82]))
303
+ except Exception:
304
+ pass
305
+ elif name == b"cancel-tcpip-forward":
306
+ try:
307
+ bind = r.string().decode() or "127.0.0.1"
308
+ sport = r.u32()
309
+ ls = rfwd.pop((bind, sport), None)
310
+ if ls is not None:
311
+ try:
312
+ ls.close()
313
+ except Exception:
314
+ pass
315
+ if want:
316
+ t.send_packet(bytes([81]))
317
+ except Exception:
318
+ if want:
319
+ try:
320
+ t.send_packet(bytes([82]))
321
+ except Exception:
322
+ pass
323
+ elif want:
324
+ t.send_packet(bytes([82]) if name == b"hostkeys-00@openssh.com" else bytes([81]))
325
+ continue
326
+ if m == K.MSG_KEXINIT:
327
+ peer2 = K.dec_kexinit(p)
328
+ our2 = _send_kexinit(t, initial=False)
329
+ Kenc2, _H2 = _do_kex(t, host_priv, Vc, Vs, peer2, our2)
330
+ keys2 = crypto.derive_keys(Kenc2, _H2, session_id, CIPHER, MAC)
331
+ t.stage_rekey(keys2, CIPHER, MAC, is_client=False)
332
+ t.send_packet(bytes([K.MSG_NEWKEYS])) # old keys
333
+ t.commit_rekey_send()
334
+ t._enc_on_send = True
335
+ t.recv_packet() # peer NEWKEYS (old keys)
336
+ t.commit_rekey_recv()
337
+ t._enc_on_recv = True
338
+ t.send_seq = 0
339
+ t.recv_seq = 0
340
+ continue
341
+ if m == CH.MSG_CHANNEL_DATA:
342
+ r = W.Reader(p)
343
+ r.byte()
344
+ sch, data = r.u32(), r.string()
345
+ tcp = fwd.get(sch)
346
+ if tcp is not None:
347
+ try:
348
+ tcp.sendall(data)
349
+ except Exception:
350
+ pass
351
+ continue
352
+ tcp = rchan_tcp.get(sch)
353
+ if tcp is not None:
354
+ try:
355
+ tcp.sendall(data)
356
+ except Exception:
357
+ pass
358
+ continue
359
+ if sch in shells: # echo shell: mirror stdin back as stdout
360
+ _send_locked(bytes([CH.MSG_CHANNEL_DATA]) + W.enc_u32(sch) + W.enc_string(data))
361
+ continue
362
+ if m == CH.MSG_CHANNEL_EOF:
363
+ r = W.Reader(p)
364
+ r.byte()
365
+ sch = r.u32()
366
+ if sch in shells:
367
+ shells.discard(sch)
368
+ _send_locked(bytes([CH.MSG_CHANNEL_REQUEST])
369
+ + W.enc_u32(sch)
370
+ + W.enc_string(b"exit-status")
371
+ + W.enc_bool(False)
372
+ + W.enc_u32(0))
373
+ _send_locked(bytes([CH.MSG_CHANNEL_EOF]) + W.enc_u32(sch))
374
+ _send_locked(bytes([CH.MSG_CHANNEL_CLOSE]) + W.enc_u32(sch))
375
+ try:
376
+ p2 = t.recv_packet()
377
+ except Exception:
378
+ return
379
+ if p2 and p2[0] == 1:
380
+ return
381
+ continue
382
+ if m == CH.MSG_CHANNEL_CLOSE:
383
+ r = W.Reader(p)
384
+ r.byte()
385
+ sch = r.u32()
386
+ shells.discard(sch)
387
+ tcp = fwd.pop(sch, None)
388
+ if tcp is not None:
389
+ try:
390
+ tcp.close()
391
+ except Exception:
392
+ pass
393
+ continue
394
+ tcp = rchan_tcp.pop(sch, None)
395
+ rfwd_pending.pop(sch, None)
396
+ rchan_peer.pop(sch, None)
397
+ if tcp is not None:
398
+ try:
399
+ tcp.close()
400
+ except Exception:
401
+ pass
402
+ continue
403
+ # Client-initiated close of a shell channel: acknowledge it.
404
+ try:
405
+ _send_locked(bytes([CH.MSG_CHANNEL_CLOSE]) + W.enc_u32(sch))
406
+ except Exception:
407
+ pass
408
+ continue
409
+ if m == CH.MSG_CHANNEL_OPEN_CONFIRM:
410
+ r = W.Reader(p)
411
+ r.byte()
412
+ ours = r.u32()
413
+ peer = r.u32()
414
+ tcp = rfwd_pending.pop(ours, None)
415
+ if tcp is None:
416
+ continue
417
+ rchan_peer[ours] = peer
418
+ rchan_tcp[ours] = tcp
419
+ threading.Thread(target=_rchan_relay, args=(ours, tcp), daemon=True).start()
420
+ continue
421
+ if m == CH.MSG_CHANNEL_OPEN_FAILURE:
422
+ r = W.Reader(p)
423
+ r.byte()
424
+ ours = r.u32()
425
+ tcp = rfwd_pending.pop(ours, None)
426
+ if tcp is not None:
427
+ try:
428
+ tcp.close()
429
+ except Exception:
430
+ pass
431
+ continue
432
+ if m == CH.MSG_CHANNEL_OPEN:
433
+ r = W.Reader(p)
434
+ r.byte()
435
+ ctype = r.string()
436
+ client_chan = r.u32()
437
+ if ctype == b"direct-tcpip":
438
+ r.u32()
439
+ r.u32() # window, maxpacket
440
+ host, port = r.string().decode(), r.u32()
441
+ r.string()
442
+ r.u32() # originator
443
+ try:
444
+ tcp = socket.create_connection((host, port), timeout=timeout)
445
+ except Exception:
446
+ t.send_packet(bytes([CH.MSG_CHANNEL_OPEN_FAILURE]) + W.enc_u32(client_chan) + W.enc_u32(2) + W.enc_string(b"connect failed") + W.enc_string(b""))
447
+ continue
448
+ sch = server_chan
449
+ server_chan += 1
450
+ fwd[sch] = tcp
451
+ t.send_packet(bytes([CH.MSG_CHANNEL_OPEN_CONFIRM]) + W.enc_u32(client_chan) + W.enc_u32(sch) + W.enc_u32(CH.WINDOW) + W.enc_u32(CH.MAXPKT))
452
+ threading.Thread(target=_fwd_relay, args=(client_chan, sch, tcp), daemon=True).start()
453
+ continue
454
+ t.send_packet(bytes([CH.MSG_CHANNEL_OPEN_CONFIRM]) + W.enc_u32(client_chan) + W.enc_u32(server_chan) + W.enc_u32(CH.WINDOW) + W.enc_u32(CH.MAXPKT))
455
+ elif m == CH.MSG_CHANNEL_REQUEST:
456
+ r = W.Reader(p)
457
+ r.byte()
458
+ _ch = r.u32()
459
+ name = r.string()
460
+ _wr = r.bool()
461
+ if name == b"subsystem" and r.string() == b"sftp":
462
+ t.send_packet(bytes([CH.MSG_CHANNEL_SUCCESS]))
463
+ serve_sftp(t, client_chan, sftp_root or tempfile.mkdtemp(prefix="ccssh-stub-"))
464
+ continue
465
+ if name == b"pty-req":
466
+ # Consume TERM(cols/rows/wh/modes); accept everything test-side.
467
+ r.string()
468
+ r.u32()
469
+ r.u32()
470
+ r.u32()
471
+ r.u32()
472
+ r.string()
473
+ t.send_packet(bytes([CH.MSG_CHANNEL_SUCCESS]))
474
+ continue
475
+ if name == b"env":
476
+ r.string()
477
+ r.string()
478
+ t.send_packet(bytes([CH.MSG_CHANNEL_SUCCESS]))
479
+ continue
480
+ if name == b"window-change":
481
+ for _ in range(4):
482
+ r.u32()
483
+ if _wr: # we only reply when explicitly asked (ours sends False)
484
+ t.send_packet(bytes([CH.MSG_CHANNEL_SUCCESS]))
485
+ continue
486
+ if name == b"shell":
487
+ t.send_packet(bytes([CH.MSG_CHANNEL_SUCCESS]))
488
+ shells.add(_ch) # echo mode; EOF/CLOSE handled in the main loop
489
+ continue
490
+ if name == b"exec":
491
+ cmd = r.string().decode()
492
+ t.send_packet(bytes([CH.MSG_CHANNEL_SUCCESS]))
493
+ if cmd.startswith("echo "):
494
+ body = (cmd[5:] + "\n").encode()
495
+ elif cmd == "fail":
496
+ body = b"oops\n"
497
+ else:
498
+ body = f"stub:{cmd}\n".encode()
499
+ t.send_packet(bytes([CH.MSG_CHANNEL_DATA]) + W.enc_u32(client_chan) + W.enc_string(body))
500
+ code = 3 if cmd == "fail" else 0
501
+ t.send_packet(bytes([CH.MSG_CHANNEL_REQUEST]) + W.enc_u32(client_chan) + W.enc_string(b"exit-status") + W.enc_bool(False) + W.enc_u32(code))
502
+ t.send_packet(bytes([CH.MSG_CHANNEL_EOF]) + W.enc_u32(client_chan))
503
+ t.send_packet(bytes([CH.MSG_CHANNEL_CLOSE]) + W.enc_u32(client_chan))
504
+ try:
505
+ p2 = t.recv_packet()
506
+ except Exception:
507
+ return
508
+ if p2 and p2[0] == 1:
509
+ return
510
+ continue
511
+ else:
512
+ t.send_packet(bytes([CH.MSG_CHANNEL_FAILURE]))
513
+
514
+
515
+ """
516
+ SFTP subsystem server (test-only, confined to a temp dir).
517
+ """
518
+
519
+ from ccssh.sftp import (
520
+ F_INIT, F_VERSION, F_OPEN, F_CLOSE, F_READ, F_WRITE,
521
+ F_OPENDIR, F_READDIR, F_REMOVE, F_MKDIR, F_REALPATH, F_STAT,
522
+ F_HANDLE, F_DATA, F_NAME, F_ATTRS, F_STATUS,
523
+ S_OK, S_EOF, S_NO_SUCH_FILE, S_FAILURE, S_OP_UNSUPPORTED,
524
+ P_READ, P_WRITE, P_APPEND, P_CREAT, P_TRUNC, P_EXCL,
525
+ )
526
+
527
+
528
+ def _status(rid: int, code: int, msg: str = "", lang: bytes = b"en") -> bytes:
529
+ body = (bytes([F_STATUS]) + W.enc_u32(rid) + W.enc_u32(code) + W.enc_string(msg.encode()) + W.enc_string(lang))
530
+ return W.enc_u32(len(body)) + body
531
+
532
+
533
+ def _reply(rid: int, ftype: int, body: bytes) -> bytes:
534
+ return W.enc_u32(1 + 4 + len(body)) + bytes([ftype]) + W.enc_u32(rid) + body
535
+
536
+
537
+ def _attrs_of(st: os.stat_result) -> bytes:
538
+ return (W.enc_u32(0x01 | 0x04 | 0x08) + W.enc_u64(st.st_size) + W.enc_u32(statmod.S_IMODE(st.st_mode)) + W.enc_u32(int(st.st_atime)) + W.enc_u32(int(st.st_mtime)))
539
+
540
+
541
+ def serve_sftp(t, client_chan: int, root: str) -> None:
542
+ """Serve SFTP on a channel. Returns on CHANNEL_CLOSE (after replying CLOSE)."""
543
+ os.makedirs(root, exist_ok=True)
544
+ handles: dict[bytes, object] = {}
545
+ counter = 0
546
+
547
+ def new_handle() -> bytes:
548
+ nonlocal counter
549
+ counter += 1
550
+ return W.enc_u32(counter)[-4:] + os.urandom(4)
551
+
552
+ def resolve(p: str) -> str:
553
+ full = os.path.normpath(os.path.join(root, p.lstrip("/")))
554
+ if full != root and not full.startswith(root + os.sep):
555
+ raise PermissionError("escape")
556
+ return full
557
+
558
+ def send_sftp(payload: bytes) -> None:
559
+ t.send_packet(bytes([CH.MSG_CHANNEL_DATA]) + W.enc_u32(client_chan) + W.enc_string(payload))
560
+
561
+ while True:
562
+ p = t.recv_packet()
563
+ m = p[0]
564
+ r = W.Reader(p)
565
+ r.byte()
566
+ if m == K.MSG_KEXINIT:
567
+ return # rekey requested: back to the main loop
568
+ if m == CH.MSG_CHANNEL_CLOSE:
569
+ r.u32() # client channel id (single-channel stub)
570
+ t.send_packet(bytes([CH.MSG_CHANNEL_CLOSE]) + W.enc_u32(client_chan))
571
+ return
572
+ if m != CH.MSG_CHANNEL_DATA:
573
+ continue
574
+ r.u32() # client channel id (single-channel stub)
575
+ blob = r.string()
576
+ br = W.Reader(blob)
577
+ ln = br.u32()
578
+ body = br.raw(ln)
579
+ cr = W.Reader(body)
580
+ ftype = cr.byte()
581
+ if ftype == F_INIT: # INIT/VERSION carry no request id
582
+ ver = cr.u32()
583
+ send_sftp(W.enc_u32(5) + bytes([F_VERSION]) + W.enc_u32(min(ver, 3)))
584
+ continue
585
+ rid = cr.u32()
586
+ rest = body[5:]
587
+ try:
588
+ out = handle_request(ftype, rid, rest, resolve, handles, new_handle)
589
+ except PermissionError:
590
+ out = _status(rid, S_FAILURE, "escape denied")
591
+ except FileNotFoundError:
592
+ out = _status(rid, S_NO_SUCH_FILE, "no such file")
593
+ except IsADirectoryError:
594
+ out = _status(rid, S_FAILURE, "is a directory")
595
+ except Exception as e:
596
+ out = _status(rid, S_FAILURE, str(e)[:200])
597
+ if out is not None:
598
+ send_sftp(out)
599
+
600
+
601
+ def handle_request(ftype: int, rid: int, rest: bytes, resolve, handles: dict, new_handle):
602
+ r = W.Reader(rest)
603
+ if ftype == F_REALPATH:
604
+ p = resolve(r.string().decode())
605
+ rel = os.path.relpath(p, _root_of(resolve))
606
+ rel = "/" if rel == "." else "/" + rel
607
+ attrs = W.enc_u32(0)
608
+ return _reply(rid, F_NAME, W.enc_u32(1) + W.enc_string(rel.encode()) + W.enc_string(rel.encode()) + attrs)
609
+ if ftype == F_STAT:
610
+ st = os.stat(resolve(r.string().decode()))
611
+ return _reply(rid, F_ATTRS, _attrs_of(st))
612
+ if ftype == F_OPENDIR:
613
+ full = resolve(r.string().decode())
614
+ entries = sorted(os.listdir(full))
615
+ h = new_handle()
616
+ handles[h] = {"dir": full, "entries": entries, "i": 0}
617
+ return _reply(rid, F_HANDLE, W.enc_string(h))
618
+ if ftype == F_READDIR:
619
+ h = r.string()
620
+ stt = handles.get(h)
621
+ if not isinstance(stt, dict) or "entries" not in stt:
622
+ return _status(rid, S_FAILURE, "bad handle")
623
+ if stt["i"] >= len(stt["entries"]):
624
+ return _status(rid, S_EOF, "eof")
625
+ chunk = stt["entries"][stt["i"]:stt["i"] + 32]
626
+ stt["i"] += len(chunk)
627
+ body = W.enc_u32(len(chunk))
628
+ for name in chunk:
629
+ fp = os.path.join(stt["dir"], name)
630
+ try:
631
+ at = _attrs_of(os.stat(fp))
632
+ except OSError:
633
+ at = W.enc_u32(0)
634
+ body += W.enc_string(name.encode()) + W.enc_string(name.encode()) + at
635
+ return _reply(rid, F_NAME, body)
636
+ if ftype == F_OPEN:
637
+ path, flags = r.string().decode(), r.u32()
638
+ _attrs_skip(r)
639
+ full = resolve(path)
640
+ if flags & P_EXCL and os.path.exists(full):
641
+ return _status(rid, S_FAILURE, "exists")
642
+ mode = "r+b" if (flags & P_READ and flags & P_WRITE) else ("rb" if flags & P_READ else "w+b")
643
+ if flags & P_APPEND:
644
+ mode = "a+b"
645
+ if flags & P_CREAT and not os.path.exists(full):
646
+ open(full, "wb").close()
647
+ if not (flags & P_WRITE):
648
+ mode = "rb"
649
+ if flags & P_TRUNC:
650
+ open(full, "wb").close()
651
+ fo = open(full, mode)
652
+ h = new_handle()
653
+ handles[h] = fo
654
+ return _reply(rid, F_HANDLE, W.enc_string(h))
655
+ if ftype == F_READ:
656
+ h, off, ln = r.string(), r.u64(), r.u32()
657
+ fo = handles.get(h)
658
+ if fo is None or isinstance(fo, dict):
659
+ return _status(rid, S_FAILURE, "bad handle")
660
+ fo.seek(off)
661
+ data = fo.read(min(ln, 32768))
662
+ if not data:
663
+ return _status(rid, S_EOF, "eof")
664
+ return _reply(rid, F_DATA, W.enc_string(data))
665
+ if ftype == F_WRITE:
666
+ h, off, data = r.string(), r.u64(), r.string()
667
+ fo = handles.get(h)
668
+ if fo is None or isinstance(fo, dict):
669
+ return _status(rid, S_FAILURE, "bad handle")
670
+ fo.seek(off)
671
+ fo.write(data)
672
+ fo.flush()
673
+ return _status(rid, S_OK, "")
674
+ if ftype == F_CLOSE:
675
+ h = r.string()
676
+ fo = handles.pop(h, None)
677
+ if fo is not None and not isinstance(fo, dict):
678
+ fo.close()
679
+ return _status(rid, S_OK, "")
680
+ if ftype == F_REMOVE:
681
+ os.unlink(resolve(r.string().decode()))
682
+ return _status(rid, S_OK, "")
683
+ if ftype == F_MKDIR:
684
+ full = resolve(r.string().decode())
685
+ _attrs_skip(r)
686
+ os.mkdir(full)
687
+ return _status(rid, S_OK, "")
688
+ return _status(rid, S_OP_UNSUPPORTED, f"op {ftype}")
689
+
690
+
691
+ def _root_of(resolve) -> str:
692
+ return resolve("/")
693
+
694
+
695
+ def _attrs_skip(r: W.Reader) -> None:
696
+ fl = r.u32()
697
+ if fl & 0x01:
698
+ r.u64()
699
+ if fl & 0x02:
700
+ r.u32()
701
+ r.u32()
702
+ if fl & 0x04:
703
+ r.u32()
704
+ if fl & 0x08:
705
+ r.u32()
706
+ r.u32()
707
+ if fl & 0x80000000:
708
+ for _ in range(r.u32()):
709
+ r.string()
710
+ r.string()