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/__init__.py +21 -0
- ccssh/__main__.py +5 -0
- ccssh/algs.py +290 -0
- ccssh/async_client.py +419 -0
- ccssh/auth.py +170 -0
- ccssh/certs.py +283 -0
- ccssh/chacha.py +117 -0
- ccssh/channel.py +124 -0
- ccssh/chansock.py +227 -0
- ccssh/cli.py +889 -0
- ccssh/client.py +1274 -0
- ccssh/crypto.py +316 -0
- ccssh/dh_groups.py +92 -0
- ccssh/forward.py +872 -0
- ccssh/gcm.py +87 -0
- ccssh/hostcheck.py +217 -0
- ccssh/hostkeys.py +250 -0
- ccssh/kex.py +222 -0
- ccssh/kex_ext.py +429 -0
- ccssh/mldsa.py +210 -0
- ccssh/mlkem.py +367 -0
- ccssh/mux.py +301 -0
- ccssh/sftp.py +270 -0
- ccssh/shell.py +257 -0
- ccssh/sk.py +224 -0
- ccssh/sntrup.py +325 -0
- ccssh/stub.py +710 -0
- ccssh/transport.py +737 -0
- ccssh/umac.py +262 -0
- ccssh/wire.py +199 -0
- ccssh-1.0.0.dist-info/METADATA +442 -0
- ccssh-1.0.0.dist-info/RECORD +36 -0
- ccssh-1.0.0.dist-info/WHEEL +5 -0
- ccssh-1.0.0.dist-info/entry_points.txt +2 -0
- ccssh-1.0.0.dist-info/licenses/LICENSE +21 -0
- ccssh-1.0.0.dist-info/top_level.txt +1 -0
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()
|