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/client.py ADDED
@@ -0,0 +1,1274 @@
1
+ """
2
+ Synchronous SSH client over the native ccssh transport.
3
+
4
+ Supports password, publickey (ed25519 / ecdsa / rsa / mldsa44-ed25519),
5
+ OpenSSH certificates, custom ports, exec channels, SFTP subsystem,
6
+ local forwarding, manual/auto rekey, and strict/accept-new/off host-key
7
+ policies.
8
+ """
9
+ from __future__ import annotations
10
+ import base64
11
+ import hashlib
12
+ import os
13
+ import socket
14
+ import threading
15
+ import time
16
+
17
+ from cryptography.hazmat.primitives import serialization
18
+ from cryptography.hazmat.primitives.asymmetric import ec, rsa
19
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
20
+
21
+ from ccssh import algs
22
+ from ccssh import auth as A
23
+ from ccssh import channel as CH
24
+ from ccssh import chacha as _chacha_mod
25
+ from ccssh import chansock as _chansock_mod
26
+ from ccssh import crypto
27
+ from ccssh import forward as _forward_mod
28
+ from ccssh import gcm as _gcm_mod
29
+ from ccssh import hostkeys as HK
30
+ from ccssh import kex as K0
31
+ from ccssh import kex_ext as KX
32
+ from ccssh import sftp as SFTP
33
+ from ccssh.mldsa import KeyPair as MldsaKeyPair
34
+ from ccssh.sk import SkKey
35
+ from ccssh.transport import SyncTransport, CCSSHError
36
+ from ccssh import wire as W
37
+
38
+ VERSION = b"SSH-2.0-CC_1.0.0"
39
+ CIPHER_DEFAULT = algs.SUPPORTED_CIPHERS
40
+ MAC_DEFAULT = algs.SUPPORTED_MACS
41
+ HOSTKEY_DEFAULT = algs.SUPPORTED_HOSTKEYS
42
+
43
+
44
+ class AuthError(CCSSHError):
45
+ pass
46
+
47
+
48
+ class HostKeyError(CCSSHError):
49
+ pass
50
+
51
+
52
+ def _load_user_priv(key_path: str, passphrase: str | None):
53
+ path = os.path.expanduser(key_path)
54
+ if os.name == "posix":
55
+ try:
56
+ if os.stat(path).st_mode & 0o077:
57
+ raise AuthError(f"key {path} is group/world-readable (chmod 600 it)")
58
+ except AuthError:
59
+ raise
60
+ except OSError:
61
+ pass # missing file: let the open below report it
62
+ with open(path, "rb") as f:
63
+ # Bounded single read (no getsize/read TOCTOU: a file grown past
64
+ # the cap between two syscalls must not OOM this process).
65
+ data = f.read((1 << 20) + 1)
66
+ if len(data) > (1 << 20):
67
+ raise AuthError(f"key file {path} too large")
68
+ try:
69
+ return serialization.load_ssh_private_key(
70
+ data, password=passphrase.encode() if passphrase else None)
71
+ except Exception:
72
+ pass
73
+ # Fallback for ssh-mldsa44-ed25519 files unknown to this cryptography build.
74
+ from ccssh.mldsa import load_openssh_private
75
+ try:
76
+ return load_openssh_private(path, passphrase.encode() if passphrase else None)
77
+ except Exception:
78
+ pass
79
+ # Fallback for FIDO2 security-key files (sk-ssh-ed25519 / sk-ecdsa-sha2).
80
+ from ccssh.sk import load_openssh_private as sk_load
81
+ try:
82
+ return sk_load(path, passphrase.encode() if passphrase else None)
83
+ except Exception:
84
+ pass
85
+ # Re-raise the original error for anything else, typed as AuthError so
86
+ # the login loop (passphrase prompts, next-identity fallback) sees it.
87
+ try:
88
+ return serialization.load_ssh_private_key(
89
+ data, password=passphrase.encode() if passphrase else None)
90
+ except AuthError:
91
+ raise
92
+ except Exception as e:
93
+ raise AuthError(f"cannot load key {path}: {e}") from e
94
+
95
+
96
+ def _user_blob(priv, cert_path: str | None):
97
+ """Returns (alg, key_blob). Certificates pass through opaquely from file."""
98
+ if cert_path:
99
+ with open(os.path.expanduser(cert_path), encoding="utf-8") as f:
100
+ p = f.read().strip().split()
101
+ if len(p) < 2:
102
+ raise AuthError(f"bad certificate file {cert_path}")
103
+ try:
104
+ return p[0].encode(), base64.b64decode(p[1], validate=True)
105
+ except Exception as e:
106
+ raise AuthError(f"bad certificate file {cert_path}: {e}") from e
107
+ if isinstance(priv, MldsaKeyPair):
108
+ return b"ssh-mldsa44-ed25519", priv.public_blob()
109
+ if isinstance(priv, SkKey):
110
+ return priv.alg.encode(), priv.public_blob()
111
+ if isinstance(priv, Ed25519PrivateKey):
112
+ pub = priv.public_key().public_bytes_raw()
113
+ return b"ssh-ed25519", W.enc_string(b"ssh-ed25519") + W.enc_string(pub)
114
+ if isinstance(priv, ec.EllipticCurvePrivateKey):
115
+ cn = priv.curve.name
116
+ alg = {"secp256r1": "ecdsa-sha2-nistp256", "secp384r1": "ecdsa-sha2-nistp384", "secp521r1": "ecdsa-sha2-nistp521"}[cn]
117
+ curve_ssh = {"secp256r1": b"nistp256", "secp384r1": b"nistp384", "secp521r1": b"nistp521"}[cn]
118
+ size = (priv.key_size + 7) // 8
119
+ nums = priv.public_key().public_numbers()
120
+ q = b"\x04" + nums.x.to_bytes(size, "big") + nums.y.to_bytes(size, "big")
121
+ return alg.encode(), W.enc_string(alg.encode()) + W.enc_string(curve_ssh) + W.enc_string(q)
122
+ if isinstance(priv, rsa.RSAPrivateKey):
123
+ return b"ssh-rsa", None # blob is built from pub numbers below
124
+ try:
125
+ from cryptography.hazmat.primitives.asymmetric import dsa as _dsa_mod
126
+ if isinstance(priv, _dsa_mod.DSAPrivateKey):
127
+ nums = priv.public_key().public_numbers()
128
+ p = nums.parameter_numbers.p
129
+ blob = (W.enc_string(b"ssh-dss")
130
+ + W.enc_mpint(p)
131
+ + W.enc_mpint(nums.parameter_numbers.q)
132
+ + W.enc_mpint(nums.parameter_numbers.g)
133
+ + W.enc_mpint(nums.y))
134
+ return b"ssh-dss", blob
135
+ except AuthError:
136
+ raise
137
+ except Exception:
138
+ pass
139
+ raise AuthError(f"unsupported private key type {type(priv)}")
140
+
141
+
142
+ class ShellSession:
143
+ """
144
+ An open pty+shell channel (see CCSSHClient.open_shell).
145
+
146
+ Single-reader model: exactly one thread may consume packets from the
147
+ transport (ccssh.shell's pump, or your own recv loop in scripts).
148
+ Sends are lock-guarded and honor the server's channel window.
149
+ close() only SENDS close and reports the last exit status seen by the
150
+ reader; the reader must keep draining until the peer's CLOSE arrives.
151
+ """
152
+
153
+ def __init__(self, client: "CCSSHClient", srv: int, window: int, maxpacket: int, chan: int | None = None):
154
+ self._client = client
155
+ self.srv = srv
156
+ # Client-side channel id (incoming recipient). Older callers pass
157
+ # only srv; then incoming checks accept srv (single-channel compat).
158
+ self.chan = srv if chan is None else chan
159
+ self._window = window
160
+ # Honor the server's maxpacket but never exceed ours: a rogue
161
+ # server announcing megabytes would make enc_data raise mid-send.
162
+ self._maxpacket = max(1024, min(maxpacket, CH.MAXPKT))
163
+ self._lock = threading.Lock()
164
+ self._closed = False
165
+ self.no_pty = False
166
+ self.exit_status: int | None = None
167
+ self.eof_received = False
168
+ self.consumed = 0 # server->client bytes since last WINDOW_ADJUST
169
+
170
+ @property
171
+ def _t(self):
172
+ t = self._client._t
173
+ if t is None:
174
+ raise CCSSHError("connection closed")
175
+ return t
176
+
177
+ def credit(self, delta: int) -> None:
178
+ with self._lock:
179
+ self._window = CH.check_window(self._window, delta)
180
+
181
+ def add_consumed(self, n: int) -> None:
182
+ """Reader-side: record received bytes (thread-safe)."""
183
+ with self._lock:
184
+ self.consumed += n
185
+
186
+ def _ensure_window(self, need: int) -> None:
187
+ # Credits arrive on the reader thread; wait for them briefly.
188
+ deadline = time.monotonic() + self._client.timeout
189
+ while True:
190
+ with self._lock:
191
+ if self._window >= need:
192
+ self._window -= need
193
+ return
194
+ if time.monotonic() > deadline:
195
+ raise CCSSHError("shell: send window exhausted, no adjust")
196
+ time.sleep(0.01)
197
+
198
+ def send(self, data: bytes) -> None:
199
+ """Send stdin bytes, split to the server's maxpacket."""
200
+ if not data:
201
+ return
202
+ t = self._t
203
+ off = 0
204
+ while off < len(data):
205
+ chunk = data[off:off + self._maxpacket]
206
+ self._ensure_window(len(chunk))
207
+ t.send_packet(CH.enc_data(self.srv, chunk))
208
+ off += len(chunk)
209
+
210
+ def send_eof(self) -> None:
211
+ try:
212
+ self._t.send_packet(CH.enc_eof(self.srv))
213
+ except Exception:
214
+ pass
215
+
216
+ def window_change(self, cols: int, rows: int) -> None:
217
+ try:
218
+ self._t.send_packet(CH.enc_window_change(self.srv, cols, rows))
219
+ except Exception:
220
+ pass
221
+
222
+ def window_adjust(self) -> None:
223
+ """Credit the server for consumed output (reader calls this)."""
224
+ with self._lock:
225
+ if self.consumed < CH.WINDOW_THRESHOLD:
226
+ return
227
+ delta = self.consumed
228
+ try:
229
+ self._t.send_packet(CH.enc_window_adjust(self.srv, delta))
230
+ except Exception:
231
+ return # keep the credit: a later call retries a larger adjust
232
+ with self._lock:
233
+ self.consumed -= delta
234
+
235
+ def close(self) -> int | None:
236
+ """Send CLOSE (idempotent). Returns the last known exit status."""
237
+ if not self._closed:
238
+ self._closed = True
239
+ try:
240
+ self._t.send_packet(CH.enc_close(self.srv))
241
+ except Exception:
242
+ pass
243
+ return self.exit_status
244
+
245
+ def _peer_closed(self) -> None:
246
+ """
247
+ Peer sent CLOSE: drop the mux registration (pump calls this).
248
+
249
+ Unregistering here (not in close()) keeps the queue alive while
250
+ the reader still drains the goodbye; close() alone must not
251
+ strand a blocked reader.
252
+ """
253
+ try:
254
+ self._client._unreg(self.chan)
255
+ except Exception:
256
+ pass
257
+
258
+
259
+ class CCSSHClient:
260
+ def __init__(self, *, timeout: float = 10.0, known_hosts: str | list[str] | None = None, host_policy: str = "strict", kex: list[str] | None = None, ciphers: list[str] | None = None, macs: list[str] | None = None, hostkeys: list[str] | None = None, rekey_bytes: int = 4 * 1024**3, rekey_interval: float = 3600.0, verbose: int = 0, ask_callback=None):
261
+ if host_policy not in ("strict", "accept-new", "ask", "off"):
262
+ raise ValueError(f"bad host_policy {host_policy!r} (must be strict|accept-new|ask|off)")
263
+ self.timeout = timeout
264
+ self.known_hosts = [os.path.expanduser(p) for p in known_hosts] if isinstance(known_hosts, list) else (os.path.expanduser(known_hosts) if known_hosts else None)
265
+ self.host_policy = host_policy
266
+ self.ask_callback = ask_callback
267
+ self.kex_list = algs.normalize_list(algs.SUPPORTED_KEX, kex) or algs.default_kex()
268
+ self.cipher_list = algs.normalize_list(algs.SUPPORTED_CIPHERS, ciphers) or CIPHER_DEFAULT
269
+ self.mac_list = algs.normalize_list(algs.SUPPORTED_MACS, macs) or MAC_DEFAULT
270
+ self.hostkey_list = algs.normalize_list(algs.SUPPORTED_HOSTKEYS, hostkeys) or HOSTKEY_DEFAULT
271
+ self.rekey_bytes = rekey_bytes
272
+ self.rekey_interval = rekey_interval
273
+ self.verbose = verbose
274
+ self._t: SyncTransport | None = None
275
+ self._sid: bytes | None = None
276
+ self._cipher = "none"
277
+ self._kex = "none"
278
+ self._mac = "none"
279
+ self._strict = False
280
+ self._chan_next = 1
281
+ self._chan_lock = threading.Lock()
282
+ self._open_chans: set[int] = set() # channels needing mux queues
283
+ self._fwds: list = []
284
+ self._host_Ks: bytes | None = None
285
+ self._host_alg = "unknown"
286
+ self._mux = None # ChannelMux once forwarding shares this connection
287
+
288
+ def _log(self, level: int, msg: str) -> None:
289
+ """OpenSSH-style debug output to stderr. level 1 (-v), 2+ (-vv)."""
290
+ if self.verbose >= level:
291
+ import sys as _sys
292
+ print(f"debug{level}: {msg}", file=_sys.stderr)
293
+
294
+ def _maybe_rekey(self) -> None:
295
+ """
296
+ Automatic rekey between operations (OpenSSH-style 4GB/60min limits).
297
+
298
+ Skipped while the mux has live channels: rekey needs a quiet
299
+ connection, and OpenSSH-transparent rekey-under-load is out of
300
+ scope (same rule as manual rekey below).
301
+ """
302
+ t = self._t
303
+ if t is None or self._sid is None:
304
+ return
305
+ if self._mux is not None and self._mux.busy():
306
+ self._log(2, "rekey: deferred (mux has live channels)")
307
+ return
308
+ if (time.monotonic() - t.kex_at > self.rekey_interval
309
+ or t.bytes_out + t.bytes_in > self.rekey_bytes):
310
+ self.rekey()
311
+
312
+ @staticmethod
313
+ def _skip_unmatched_guess(t, peer: dict, our_kex: list[str], kex_name: str) -> None:
314
+ """
315
+ RFC 4253 section 7.1: if the peer sent first_follows with a wrong
316
+ KEX guess, the packet after its KEXINIT is a decoy and must be
317
+ discarded (otherwise we would parse it as the real KEX message).
318
+ Servers never send this in practice; the guard only triggers then.
319
+ """
320
+ if not peer.get("first_follows"):
321
+ return
322
+ flags = {K0.KEX_STRICT_C, K0.KEX_STRICT_S, K0.EXT_INFO_C}
323
+ guess = next((k for k in peer["kex"] if k in our_kex and k not in flags), None)
324
+ if guess != kex_name:
325
+ t.recv_packet() # discard the decoy
326
+
327
+ def connect(self, hostname: str, *, port: int = 22, username: str, password: str | None = None, key_filename: str | None = None, cert_filename: str | None = None, passphrase: str | None = None, family: int = 0, sock: _chansock_mod.StreamSocket | None = None, kbdint_responder: A.KbdintResponder | None = None) -> "CCSSHClient":
328
+ owned = sock is None # dialled here (closable) vs jump-owned (hands off)
329
+ # A previous connection's mux (if any) belongs to its transport:
330
+ # never let a stale reader steal the new handshake.
331
+ mux, self._mux = self._mux, None
332
+ if mux is not None:
333
+ try:
334
+ mux.stop()
335
+ except Exception:
336
+ pass
337
+ if sock is None:
338
+ self._log(1, f"Connecting to {hostname} [{port}] (timeout={self.timeout}s)")
339
+ infos = self._resolve(hostname, port, family)
340
+ self._log(2, f"resolve {hostname}:{port} -> {len(infos)} addr(s): {', '.join(f'{af}:{sa}' for af, _st, _pr, _ca, sa in infos)}")
341
+ last_err: OSError | None = None
342
+ sock = None
343
+ for af, st, proto, _canon, sa in infos:
344
+ s: socket.socket | None = None
345
+ try:
346
+ self._log(2, f"dialing {sa} ...")
347
+ s = socket.socket(af, st, proto)
348
+ s.settimeout(self.timeout)
349
+ s.connect(sa)
350
+ except OSError as e:
351
+ self._log(2, f"dial {sa} failed: {e}")
352
+ last_err = e
353
+ if s is not None:
354
+ try:
355
+ s.close()
356
+ except Exception:
357
+ pass
358
+ continue
359
+ self._log(1, f"connected to {sa}")
360
+ sock = s
361
+ break
362
+ if sock is None:
363
+ raise CCSSHError(f"connect {hostname}:{port}: {last_err}")
364
+ else:
365
+ self._log(1, f"using jump-tunnel socket to {hostname}:{port}")
366
+ try:
367
+ # TCP_NODELAY also on jump-owned sockets (best effort: a
368
+ # ChannelSocket simply ignores unknown options).
369
+ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
370
+ except Exception:
371
+ pass
372
+ t = SyncTransport(sock, VERSION, timeout=self.timeout)
373
+ try:
374
+ self._log(2, f"sending version {VERSION!r}")
375
+ Vs = t.do_version()
376
+ self._log(1, f"server version: {Vs!r}")
377
+ Vc = VERSION
378
+ self._log(2, f"KEX offer: kex={self.kex_list}")
379
+ self._log(2, f"KEX offer: hostkey={self.hostkey_list} cipher={self.cipher_list} mac={self.mac_list}")
380
+ our = K0.enc_kexinit(os.urandom(16), self.kex_list + [K0.KEX_STRICT_C, K0.EXT_INFO_C], self.hostkey_list, self.cipher_list, self.cipher_list, self.mac_list, self.mac_list, ["none"], ["none"])
381
+ t.send_packet(our) # still plaintext at this point
382
+ peer_raw = t.recv_packet()
383
+ peer = K0.dec_kexinit(peer_raw)
384
+ self._log(2, f"peer KEX: kex={peer['kex']} hostkey={peer['hostkey']} cipher_c2s={peer['enc_c2s']} mac_c2s={peer['mac_c2s']}")
385
+ kex_name = K0.negotiate(self.kex_list, peer["kex"])
386
+ host_alg = K0.negotiate(self.hostkey_list, peer["hostkey"])
387
+ cipher = K0.negotiate_symmetric(self.cipher_list, peer["enc_c2s"], peer["enc_s2c"], "cipher")
388
+ mac = K0.negotiate_symmetric(self.mac_list, peer["mac_c2s"], peer["mac_s2c"], "mac")
389
+ strict = K0.peer_wants_strict(peer["kex"], is_client=True)
390
+ self._log(1, f"kex: {kex_name} hostkey: {host_alg} cipher: {cipher} mac: {mac} strict: {strict}")
391
+ Ic, Is = K0.I_FOR_H(our), K0.I_FOR_H(peer["payload_full"])
392
+ self._skip_unmatched_guess(t, peer, self.kex_list, kex_name)
393
+ self._log(2, "running KEX ...")
394
+ K_enc, Hh, Ks, hashfn = self._run_kex(t, kex_name, Vc, Vs, Ic, Is, host_alg)
395
+ try:
396
+ fp = HK.fingerprint(Ks)
397
+ except Exception:
398
+ fp = "<unparseable>"
399
+ self._log(1, f"server host key: {host_alg} {fp} (policy={self.host_policy})")
400
+ self._check_hostkey(hostname, port, Ks, host_alg)
401
+ self._log(1, "host key verified")
402
+ self._host_Ks = Ks
403
+ self._host_alg = host_alg
404
+ keys = self._derive(cipher, mac, K_enc, Hh, hashfn)
405
+ t.activate(keys, cipher, mac, is_client=True)
406
+ t.send_packet(bytes([K0.MSG_NEWKEYS])) # old (plaintext) keys
407
+ t._enc_on_send = True
408
+ pkt = t.recv_packet()
409
+ if not pkt or pkt[0] != K0.MSG_NEWKEYS:
410
+ raise CCSSHError("expected NEWKEYS")
411
+ t._enc_on_recv = True
412
+ self._strict = K0.peer_wants_strict(peer["kex"], is_client=True)
413
+ if self._strict:
414
+ t.send_seq = 0
415
+ t.recv_seq = 0
416
+ self._t, self._sid, self._cipher, self._mac = t, Hh, cipher, mac
417
+ self._kex = kex_name
418
+ self._log(1, f"negotiated: kex={kex_name} cipher={cipher} mac={mac} strict={self._strict}")
419
+ self._log(2, f"session id: {Hh.hex()[:32]}...")
420
+ t.send_packet(A.enc_service_request())
421
+ for _ in range(16): # EXT_INFO repeats are skipped, not fatal
422
+ pkt = t.recv_packet()
423
+ if pkt and pkt[0] == K0.MSG_EXT_INFO:
424
+ self._log(2, "got EXT_INFO from server")
425
+ continue
426
+ break
427
+ if not pkt or pkt[0] != A.MSG_SERVICE_ACCEPT:
428
+ raise CCSSHError("no ssh-userauth service")
429
+ self._log(2, "userauth service accepted")
430
+ if key_filename or cert_filename:
431
+ self._log(1, f"auth: publickey key={key_filename} cert={cert_filename}")
432
+ self._auth_pubkey(username, key_filename, cert_filename, passphrase)
433
+ self._log(1, "auth: publickey accepted")
434
+ elif password is not None:
435
+ self._log(1, "auth: password method")
436
+ t.send_packet(A.enc_password_request(username, password))
437
+ if (t.recv_packet() or b"\x00")[0] != A.MSG_USERAUTH_SUCCESS:
438
+ # Fall back to keyboard-interactive (RFC 4256) with the
439
+ # same secret: servers like serveo.net only offer that
440
+ # method. The original error is kept if it fails too.
441
+ self._log(1, "auth: password rejected, trying keyboard-interactive")
442
+ try:
443
+ self._auth_kbdint(username, lambda prompts: [password] * len(prompts))
444
+ except AuthError:
445
+ raise AuthError("password rejected") from None
446
+ else:
447
+ self._log(1, "auth: password accepted")
448
+ elif kbdint_responder is not None:
449
+ self._auth_kbdint(username, kbdint_responder)
450
+ else:
451
+ raise AuthError("provide password or key_filename")
452
+ except Exception:
453
+ # An externally owned jump socket (sock=) stays open for the
454
+ # caller to reuse; only a socket we dialled ourselves is closed.
455
+ if owned:
456
+ try:
457
+ t.sock.close()
458
+ except Exception:
459
+ pass
460
+ # Do not close an externally owned jump socket here; the caller
461
+ # owns `sock=` (ProxyJump). We only drop our transport reference.
462
+ self._t = None
463
+ raise
464
+ return self
465
+
466
+ @staticmethod
467
+ def _resolve(hostname: str, port: int, family: int) -> list:
468
+ """getaddrinfo filtered by family (0 = any; -4/-6 map to AF_INET/6)."""
469
+ try:
470
+ infos = socket.getaddrinfo(hostname, port, family=family or socket.AF_UNSPEC, type=socket.SOCK_STREAM)
471
+ except OSError as e:
472
+ raise CCSSHError(f"resolve {hostname}: {e}") from e
473
+ if not infos:
474
+ raise CCSSHError(f"no addresses for {hostname}")
475
+ return infos
476
+
477
+ def _run_kex(self, t: SyncTransport, name: str, Vc: bytes, Vs: bytes, Ic: bytes, Is: bytes, host_alg: str = "ssh-ed25519"):
478
+ name = KX.KEX_ALIASES.get(name, name)
479
+ hashfn = KX.kdf_hashfn(name)
480
+ if name == "curve25519-sha256":
481
+ c = K0.Curve25519Client()
482
+ t.send_packet(c.init_msg())
483
+ K_ecdh, Hh, Ks = c.finish(t.recv_packet(), Vc, Vs, Ic, Is, host_alg)
484
+ return W.mpint_from_bytes(K_ecdh), Hh, Ks, hashfn
485
+ if name == "mlkem768x25519-sha256":
486
+ c = K0.MlkemHybridClient(Vc, Ic)
487
+ t.send_packet(c.init_msg())
488
+ Kr, Hh, Ks, _, _ = c.finish(t.recv_packet(), Vs, Is, host_alg)
489
+ return W.enc_string(Kr), Hh, Ks, hashfn
490
+ if name.startswith("ecdh-sha2-nistp"):
491
+ c = KX.EcdhClient(name)
492
+ t.send_packet(c.init_msg())
493
+ Ke, Hh, Ks = c.finish(t.recv_packet(), Vc, Vs, Ic, Is, host_alg)
494
+ return W.mpint_from_bytes(Ke), Hh, Ks, hashfn
495
+ if name in KX.DH_GROUPS:
496
+ c = KX.DhClient(name)
497
+ t.send_packet(c.init_msg())
498
+ Kr, Hh, Ks = c.finish(t.recv_packet(), Vc, Vs, Ic, Is, host_alg)
499
+ return W.mpint_from_bytes(Kr), Hh, Ks, hashfn
500
+ if name == "mlkem768nistp256-sha256":
501
+ c = KX.MlkemP256Client(Vc, Ic)
502
+ t.send_packet(c.init_msg())
503
+ Kr, Hh, Ks = c.finish(t.recv_packet(), Vs, Is, host_alg)
504
+ return W.enc_string(Kr), Hh, Ks, hashfn
505
+ if name in ("diffie-hellman-group-exchange-sha256", "diffie-hellman-group-exchange-sha1"):
506
+ c = KX.GexClient(hashfn=hashlib.sha256 if name.endswith("sha256") else hashlib.sha1)
507
+ t.send_packet(c.request_msg())
508
+ t.send_packet(c.init_msg(t.recv_packet()))
509
+ Kr, Hh, Ks = c.finish(t.recv_packet(), Vc, Vs, Ic, Is, host_alg)
510
+ return W.mpint_from_bytes(Kr), Hh, Ks, hashfn
511
+ if name == "sntrup761x25519-sha512":
512
+ c = KX.SntrupClient(Vc, Ic)
513
+ t.send_packet(c.init_msg())
514
+ Kr, Hh, Ks, _, _ = c.finish(t.recv_packet(), Vs, Is, host_alg)
515
+ return W.enc_string(Kr), Hh, Ks, hashfn
516
+ raise CCSSHError(f"KEX {name} not wired to the transport")
517
+
518
+ @staticmethod
519
+ def _derive(cipher: str, mac: str, K_enc: bytes, Hh: bytes, hashfn, session_id: bytes | None = None) -> dict[str, bytes]:
520
+ sid = Hh if session_id is None else session_id
521
+ if cipher in crypto.CIPHER_SPECS or cipher in crypto.CBC_SPECS:
522
+ return crypto.derive_keys(K_enc, Hh, sid, cipher, mac, hashfn)
523
+ if cipher in ("aes128-gcm@openssh.com", "aes256-gcm@openssh.com"):
524
+ return _gcm_mod.derive_gcm_keys(K_enc, Hh, sid, cipher, hashfn)
525
+ if cipher == "chacha20-poly1305@openssh.com":
526
+ return _chacha_mod.derive_chacha_keys(K_enc, Hh, sid, hashfn)
527
+ raise CCSSHError(f"key derivation for {cipher}")
528
+
529
+ def _check_hostkey(self, host: str, port: int, Ks: bytes, alg: str) -> None:
530
+ from ccssh.hostcheck import check_hostkey, HostKeyError as _HKE
531
+ try:
532
+ check_hostkey(host, port, Ks, alg, policy=self.host_policy, known_hosts=self.known_hosts, ask_callback=getattr(self, "ask_callback", None))
533
+ except _HKE as e:
534
+ raise HostKeyError(str(e)) from e
535
+
536
+ def _auth_pubkey(self, username: str, key_path: str | None, cert_path: str | None, passphrase: str | None) -> None:
537
+ if not self._t or self._sid is None:
538
+ raise CCSSHError("call .connect() first")
539
+ if not key_path:
540
+ raise AuthError("cert without key_filename is unsupported")
541
+ priv = _load_user_priv(key_path, passphrase)
542
+ alg, blob = _user_blob(priv, cert_path)
543
+ if blob is None: # RSA: build the blob from public numbers
544
+ nums = priv.public_key().public_numbers()
545
+ blob = W.enc_string(b"ssh-rsa") + W.enc_mpint(nums.e) + W.enc_mpint(nums.n)
546
+ alg = b"rsa-sha2-256"
547
+ self._log(1, f"auth: publickey probe {alg.decode(errors='replace')} key={key_path}")
548
+ self._t.send_packet(A.enc_pubkey_probe(username, alg, blob))
549
+ r = self._t.recv_packet()
550
+ if r and r[0] == A.MSG_USERAUTH_SUCCESS:
551
+ self._log(1, "auth: server accepted the key without signature")
552
+ return
553
+ if r and r[0] == A.MSG_USERAUTH_PK_OK:
554
+ self._log(2, "auth: server PK_OK, sending signature")
555
+ else:
556
+ self._log(2, f"auth: probe answered {(r[0] if r else None)}, trying signed anyway")
557
+ data = A.auth_sign_blob(self._sid, username, alg, blob)
558
+ outer = HK.sign_userauth(priv, alg.decode(), data)
559
+ self._t.send_packet(A.enc_pubkey_signed(username, alg, blob, outer))
560
+ if (self._t.recv_packet() or b"\x00")[0] != A.MSG_USERAUTH_SUCCESS:
561
+ self._log(1, f"auth: publickey key={key_path} rejected by server")
562
+ raise AuthError("publickey rejected")
563
+ self._log(1, f"auth: publickey key={key_path} accepted")
564
+ def _auth_kbdint(self, username: str, responder: A.KbdintResponder) -> None:
565
+ """Keyboard-interactive auth (RFC 4256): probe, answer each
566
+ INFO_REQUEST via `responder`, stop at SUCCESS/FAILURE. Bounded
567
+ rounds and prompt counts (rogue servers must not loop us)."""
568
+ t = self._t
569
+ if t is None or self._sid is None:
570
+ raise CCSSHError("call .connect() first")
571
+ self._log(1, "auth: keyboard-interactive method")
572
+ t.send_packet(A.enc_kbdint_probe(username))
573
+ for _ in range(A.MAX_KBDINT_ROUNDS):
574
+ p = t.recv_packet() or b"\x00"
575
+ m = p[0]
576
+ if m == A.MSG_USERAUTH_SUCCESS:
577
+ self._log(1, "auth: keyboard-interactive accepted")
578
+ return
579
+ if m == A.MSG_USERAUTH_FAILURE:
580
+ raise AuthError("keyboard-interactive rejected")
581
+ if m != A.MSG_USERAUTH_INFO_REQUEST:
582
+ raise AuthError(f"keyboard-interactive: unexpected reply {m}")
583
+ try:
584
+ info = A.dec_info_request(p)
585
+ except W.WireError as e:
586
+ raise AuthError(f"keyboard-interactive: bad prompt: {e}") from e
587
+ self._log(2, f"auth: kbdint {len(info['prompts'])} prompt(s): "
588
+ f"{[text for text, _echo in info['prompts']]}")
589
+ try:
590
+ answers = responder(info["prompts"])
591
+ except (KeyboardInterrupt, SystemExit):
592
+ raise
593
+ except Exception as e:
594
+ raise AuthError(f"keyboard-interactive responder failed: {e}") from e
595
+ if len(answers) != len(info["prompts"]):
596
+ raise AuthError("keyboard-interactive responder gave wrong answer count")
597
+ t.send_packet(A.enc_info_response([a.encode() for a in answers]))
598
+ raise AuthError("keyboard-interactive rejected (too many rounds)")
599
+
600
+ def _rx(self, chan: int, timeout: float | None = None) -> bytes:
601
+ """Next inbound packet for a channel: via the mux when one is
602
+ active (forwarding shares the connection), else a direct
603
+ transport read (historical behavior, unchanged).
604
+
605
+ `timeout` is honored only under a mux (mux.recv_chan); without
606
+ one the transport-level read timeout bounds each read instead.
607
+ Callers that need a hard deadline keep their own (see _top_up,
608
+ _await_request, _make_closer).
609
+ """
610
+ mux = self._mux
611
+ if mux is not None:
612
+ return mux.recv_chan(chan, timeout)
613
+ t = self._t
614
+ if t is None:
615
+ raise CCSSHError("connection closed")
616
+ return t.recv_packet()
617
+
618
+ def _expect(self, chan: int) -> None:
619
+ """Prime request-reply correlation (call BEFORE sending a channel
620
+ request; no-op without a mux). Prefer _send_request(), which makes
621
+ the pair atomic across threads."""
622
+ mux = self._mux
623
+ if mux is not None:
624
+ mux.expect_reply(chan)
625
+
626
+ def _send_request(self, chan: int, payload: bytes) -> None:
627
+ """expect_reply()+send as one atomic step.
628
+
629
+ 99/100 carry no channel id, so two threads doing expect(A)+send(A)
630
+ and expect(B)+send(B) unlocked could cross-wire the replies. The
631
+ mux request lock keeps each pair together.
632
+ """
633
+ mux = self._mux
634
+ t = self._t
635
+ if t is None:
636
+ raise CCSSHError("connection closed")
637
+ if mux is None:
638
+ t.send_packet(payload)
639
+ return
640
+ with mux.request_lock:
641
+ mux.expect_reply(chan)
642
+ t.send_packet(payload)
643
+
644
+ def _ensure_mux(self):
645
+ """Start the channel multiplexer (first forwarder takes it)."""
646
+ if self._mux is None:
647
+ if self._t is None:
648
+ raise CCSSHError("call .connect() first")
649
+ from ccssh import mux as _mux_mod
650
+ self._mux = _mux_mod.ChannelMux(self._t).start()
651
+ with self._chan_lock:
652
+ for chan in self._open_chans:
653
+ try:
654
+ self._mux.register(chan)
655
+ except Exception:
656
+ pass
657
+ self._log(2, "channel multiplexer started (forwarding shares this connection)")
658
+ return self._mux
659
+
660
+ def _reg(self, chan: int) -> None:
661
+ """Track an open channel and register its mux queue (if any)."""
662
+ with self._chan_lock:
663
+ self._open_chans.add(chan)
664
+ if self._mux is not None:
665
+ self._mux.register(chan)
666
+
667
+ def _unreg(self, chan: int) -> None:
668
+ """Forget a closed channel and drop its mux queue (if any)."""
669
+ with self._chan_lock:
670
+ self._open_chans.discard(chan)
671
+ if self._mux is not None:
672
+ try:
673
+ self._mux.unregister(chan)
674
+ except Exception:
675
+ pass
676
+
677
+ def exec_command(self, command: str, *, timeout: float | None = None,
678
+ max_bytes: int = 16 * 1024 * 1024, raw_bytes: bool = False):
679
+ """Run one command. Returns (stdout, stderr, exit_status) as text,
680
+ or as bytes with raw_bytes=True (binary-safe, like ssh pipes)."""
681
+ if not self._t:
682
+ raise CCSSHError("call .connect() first")
683
+ self._maybe_rekey()
684
+ t = self._t
685
+ chan = self._alloc_chan()
686
+ self._reg(chan)
687
+ try:
688
+ out_b, err_b, code = self._exec_inner(t, chan, command, max_bytes)
689
+ finally:
690
+ self._unreg(chan)
691
+ if raw_bytes:
692
+ return out_b, err_b, code
693
+ return out_b.decode(errors="replace"), err_b.decode(errors="replace"), code
694
+
695
+ def _exec_inner(self, t, chan: int, command: str, max_bytes: int) -> tuple[bytes, bytes, int]:
696
+ self._log(2, f"exec: opening session channel {chan} for {command!r:.80}")
697
+ t.send_packet(CH.enc_open_session(chan))
698
+ while True:
699
+ r = W.Reader(self._rx(chan))
700
+ m0 = r.byte()
701
+ if m0 == CH.MSG_CHANNEL_OPEN_CONFIRM:
702
+ break
703
+ if m0 == CH.MSG_CHANNEL_OPEN_FAILURE:
704
+ raise CCSSHError("channel refused by server")
705
+ continue # WINDOW_ADJUST etc. before the channel exists
706
+ if r.u32() != chan:
707
+ raise CCSSHError("channel confirm for foreign channel")
708
+ srv = r.u32()
709
+ self._send_request(chan, CH.enc_exec(srv, command))
710
+ while True:
711
+ pkt = self._rx(chan) or b"\x00"
712
+ if pkt[0] == CH.MSG_CHANNEL_SUCCESS:
713
+ break
714
+ if pkt[0] == CH.MSG_CHANNEL_FAILURE:
715
+ raise CCSSHError("exec refused")
716
+ continue # WINDOW_ADJUST etc. before exec starts
717
+ out_parts: list[bytes] = []
718
+ err_parts: list[bytes] = []
719
+ out_len = err_len = 0
720
+ code = 0
721
+ consumed = 0
722
+ while True:
723
+ p = self._rx(chan)
724
+ m = p[0]
725
+ rr = W.Reader(p)
726
+ rr.byte()
727
+ if m == CH.MSG_CHANNEL_DATA:
728
+ if rr.u32() != chan:
729
+ continue
730
+ chunk = rr.string()
731
+ out_len += len(chunk)
732
+ if out_len + err_len > max_bytes:
733
+ raise CCSSHError("exec output exceeds max_bytes")
734
+ out_parts.append(chunk)
735
+ consumed += len(chunk)
736
+ elif m == CH.MSG_CHANNEL_EXTENDED_DATA:
737
+ if rr.u32() != chan:
738
+ continue
739
+ if rr.u32() != 1: # RFC 4254: only type 1 (stderr) is defined
740
+ continue
741
+ chunk = rr.string()
742
+ err_len += len(chunk)
743
+ if out_len + err_len > max_bytes:
744
+ raise CCSSHError("exec output exceeds max_bytes")
745
+ err_parts.append(chunk)
746
+ consumed += len(chunk)
747
+ elif m == CH.MSG_WINDOW_ADJUST:
748
+ continue # our sends are small; the server window suffices
749
+ elif m == CH.MSG_CHANNEL_REQUEST:
750
+ if rr.u32() != chan:
751
+ continue
752
+ if rr.string() == b"exit-status":
753
+ rr.bool()
754
+ code = rr.u32()
755
+ elif m == 20: # peer-initiated rekey needs idle channels
756
+ raise CCSSHError("peer started rekey with an open channel: close channels and call rekey()")
757
+ elif m == CH.MSG_CHANNEL_CLOSE:
758
+ if rr.u32() != chan:
759
+ continue
760
+ try:
761
+ t.send_packet(CH.enc_close(srv))
762
+ except Exception:
763
+ pass
764
+ break
765
+ if consumed >= CH.WINDOW_THRESHOLD:
766
+ try:
767
+ t.send_packet(CH.enc_window_adjust(srv, consumed))
768
+ except Exception:
769
+ pass # keep the credit; a later send retries it
770
+ else:
771
+ consumed = 0
772
+ out = b"".join(out_parts)
773
+ err = b"".join(err_parts)
774
+ self._log(2, f"exec: channel {chan}->{srv} done: stdout={len(out)}B stderr={len(err)}B exit={code}")
775
+ return out, err, code
776
+
777
+ def _alloc_chan(self) -> int:
778
+ with self._chan_lock:
779
+ if self._mux is not None:
780
+ return self._mux.alloc_chan()
781
+ n = self._chan_next
782
+ self._chan_next = n + 1
783
+ return n
784
+
785
+ def _open_session(self) -> tuple:
786
+ """
787
+ Open a session channel. Returns (transport, client_chan, server_chan,
788
+ server_window, server_maxpacket). Shared by exec/sftp/shell.
789
+ """
790
+ if not self._t:
791
+ raise CCSSHError("call .connect() first")
792
+ self._maybe_rekey()
793
+ t = self._t
794
+ chan = self._alloc_chan()
795
+ self._reg(chan)
796
+ try:
797
+ t.send_packet(CH.enc_open_session(chan))
798
+ while True:
799
+ r = W.Reader(self._rx(chan))
800
+ m0 = r.byte()
801
+ if m0 == CH.MSG_CHANNEL_OPEN_CONFIRM:
802
+ break
803
+ if m0 == CH.MSG_CHANNEL_OPEN_FAILURE:
804
+ raise CCSSHError("channel refused by server")
805
+ continue # WINDOW_ADJUST etc. before the channel exists
806
+ except Exception:
807
+ self._unreg(chan)
808
+ raise
809
+ r.u32()
810
+ srv = r.u32()
811
+ window = r.u32()
812
+ maxpacket = r.u32()
813
+ return t, chan, srv, window, maxpacket
814
+
815
+ def _await_request(self, sess: "ShellSession | None", what: str) -> bool:
816
+ """
817
+ Wait for SUCCESS/FAILURE of a channel request. WINDOW_ADJUST packets
818
+ credit the session flow; anything else is skipped. Returns True/False.
819
+ """
820
+ if self._t is None:
821
+ raise CCSSHError("call .connect() first")
822
+ if sess is None:
823
+ raise CCSSHError("channel session is required")
824
+ deadline = time.monotonic() + self.timeout
825
+ while True:
826
+ if time.monotonic() > deadline:
827
+ raise CCSSHError(f"{what}: channel request timed out")
828
+ pkt = self._rx(sess.chan) or b"\x00"
829
+ m = pkt[0]
830
+ if m == CH.MSG_CHANNEL_SUCCESS:
831
+ return True
832
+ if m == CH.MSG_CHANNEL_FAILURE:
833
+ return False
834
+ if m == CH.MSG_WINDOW_ADJUST:
835
+ try:
836
+ rr = W.Reader(pkt)
837
+ rr.byte()
838
+ if rr.u32() != sess.chan:
839
+ continue
840
+ sess.credit(rr.u32())
841
+ except ValueError as e:
842
+ raise CCSSHError(f"bad window adjust: {e}") from e
843
+ except Exception:
844
+ pass
845
+ elif m in (CH.MSG_CHANNEL_CLOSE, CH.MSG_CHANNEL_EOF):
846
+ return False # channel died instead of answering
847
+ elif m == 20: # peer-initiated rekey needs idle channels
848
+ raise CCSSHError("peer started rekey with an open channel: close channels and call rekey()")
849
+ continue
850
+
851
+ def open_shell(self, *, term: str | None = None, cols: int = 80, rows: int = 24, pty: bool = True, command: str | None = None) -> "ShellSession":
852
+ """
853
+ Open a session channel with shell (RFC 4254 6.5), with pty
854
+ (RFC 4254 6.2) unless pty=False (`ssh -T`).
855
+
856
+ With command set, runs it on the pty channel instead of a shell
857
+ (`ssh -t host command`). Returns a ShellSession for interactive
858
+ use (see ccssh.shell). A refused pty is tolerated (shell continues
859
+ without a terminal); a refused shell/exec raises CCSSHError.
860
+ Like exec, no rekey is possible while the channel is open - call
861
+ between sessions.
862
+ """
863
+ t, _chan, srv, window, maxpacket = self._open_session()
864
+ sess = ShellSession(self, srv, window, maxpacket, chan=_chan)
865
+ if pty:
866
+ self._send_request(_chan, CH.enc_pty_req(srv, term or os.environ.get("TERM", "xterm"), cols, rows))
867
+ if not self._await_request(sess, "pty-req"):
868
+ sess.no_pty = True # degraded but usable (no job control)
869
+ else:
870
+ sess.no_pty = True
871
+ if command is not None:
872
+ self._send_request(_chan, CH.enc_exec(srv, command))
873
+ if not self._await_request(sess, "exec"):
874
+ raise CCSSHError("exec refused by server")
875
+ else:
876
+ self._send_request(_chan, CH.enc_shell(srv))
877
+ if not self._await_request(sess, "shell"):
878
+ raise CCSSHError("shell refused by server")
879
+ return sess
880
+
881
+ def open_channel_socket(self, host: str, port: int, *, orig_host: str = "127.0.0.1", orig_port: int = 0) -> "_chansock_mod.ChannelSocket":
882
+ """
883
+ Open a direct-tcpip channel and wrap it as a socket (-J building
884
+ block). On a mux-shared connection the tunnel rides the mux;
885
+ otherwise it owns this connection's reads until closed: no
886
+ exec/sftp/forward alongside on a mux-free jump connection.
887
+ """
888
+ if not self._t:
889
+ raise CCSSHError("call .connect() first")
890
+ self._maybe_rekey()
891
+ t = self._t
892
+ chan = self._alloc_chan()
893
+ self._reg(chan)
894
+ mux = self._mux
895
+ try:
896
+ t.send_packet(CH.enc_open_direct_tcpip(chan, host, port, orig_host, orig_port))
897
+ while True:
898
+ r = W.Reader(self._rx(chan))
899
+ m0 = r.byte()
900
+ if m0 == CH.MSG_CHANNEL_OPEN_CONFIRM:
901
+ break
902
+ if m0 == CH.MSG_CHANNEL_OPEN_FAILURE:
903
+ raise CCSSHError("direct-tcpip refused by server")
904
+ continue # WINDOW_ADJUST etc. before the channel exists
905
+ except Exception:
906
+ self._unreg(chan)
907
+ raise
908
+ r.u32()
909
+ srv = r.u32()
910
+ window = r.u32()
911
+ maxpacket = r.u32()
912
+ return _chansock_mod.ChannelSocket(t, srv, window, maxpacket, timeout=self.timeout,
913
+ mux=mux, chan=chan)
914
+
915
+ def open_sftp(self):
916
+ """Open SFTP over a subsystem channel. Returns an SFTPClient."""
917
+ if not self._t:
918
+ raise CCSSHError("call .connect() first")
919
+ self._maybe_rekey()
920
+ t = self._t
921
+ chan = self._alloc_chan()
922
+ self._reg(chan)
923
+ try:
924
+ t.send_packet(CH.enc_open_session(chan))
925
+ while True:
926
+ r = W.Reader(self._rx(chan))
927
+ m0 = r.byte()
928
+ if m0 == CH.MSG_CHANNEL_OPEN_CONFIRM:
929
+ break
930
+ if m0 == CH.MSG_CHANNEL_OPEN_FAILURE:
931
+ raise CCSSHError("sftp: channel refused by server")
932
+ continue
933
+ except Exception:
934
+ self._unreg(chan)
935
+ raise
936
+ if r.u32() != chan:
937
+ self._unreg(chan)
938
+ raise CCSSHError("sftp: confirm for foreign channel")
939
+ srv = r.u32()
940
+ srv_window = r.u32()
941
+ srv_maxpacket = r.u32()
942
+ flow = {"window": srv_window, "maxpacket": max(1024, min(srv_maxpacket, CH.MAXPKT))}
943
+ cell: dict = {}
944
+ self._send_request(chan, CH.enc_subsystem(srv))
945
+ while True:
946
+ pkt = self._rx(chan) or b"\x00"
947
+ if pkt[0] == CH.MSG_CHANNEL_SUCCESS:
948
+ break
949
+ if pkt[0] == CH.MSG_CHANNEL_FAILURE:
950
+ raise CCSSHError("sftp: subsystem refused")
951
+ if pkt[0] == CH.MSG_WINDOW_ADJUST:
952
+ try:
953
+ rr = W.Reader(pkt)
954
+ rr.byte()
955
+ rr.u32()
956
+ flow["window"] += rr.u32()
957
+ except Exception:
958
+ pass
959
+ continue
960
+
961
+ def _top_up(need: int) -> None:
962
+ """
963
+ Top up the send window: consume packets, credit adjusts,
964
+ stash SFTP data into the client buffer (never lose replies).
965
+ """
966
+ if self._t is None:
967
+ raise CCSSHError("connection closed")
968
+ deadline = time.monotonic() + self.timeout
969
+ while flow["window"] < need:
970
+ if time.monotonic() > deadline:
971
+ raise SFTP.SFTPError("sftp: send window exhausted, no adjust")
972
+ p = self._rx(chan, timeout=max(0.01, deadline - time.monotonic()))
973
+ m = p[0]
974
+ rr = W.Reader(p)
975
+ rr.byte()
976
+ if m == CH.MSG_WINDOW_ADJUST:
977
+ if rr.u32() != chan:
978
+ continue
979
+ try:
980
+ flow["window"] = CH.check_window(flow["window"], rr.u32())
981
+ except ValueError as e:
982
+ raise SFTP.SFTPError(f"sftp: {e}") from e
983
+ elif m == CH.MSG_CHANNEL_DATA:
984
+ if rr.u32() != chan:
985
+ continue
986
+ sc0 = cell.get("sc")
987
+ if sc0 is not None:
988
+ chunk = rr.string()
989
+ if len(sc0._buf) + len(chunk) > 2 * SFTP.SFTPClient.MAX_REPLY:
990
+ raise SFTP.SFTPError("sftp: reply too big")
991
+ sc0._buf += chunk
992
+ elif m == CH.MSG_CHANNEL_CLOSE:
993
+ try:
994
+ if rr.u32() == chan:
995
+ raise SFTP.SFTPError("sftp: channel closed by server")
996
+ except SFTP.SFTPError:
997
+ raise
998
+ except Exception:
999
+ pass
1000
+ elif m == CH.MSG_CHANNEL_EOF:
1001
+ # Sticky: after EOF no more DATA will come (anything still
1002
+ # stashed is drained by _call); _recv turns it into a
1003
+ # clean failure instead of hanging on the next read.
1004
+ cell["eof"] = True
1005
+ continue
1006
+
1007
+ def _send(_srv: int, data: bytes) -> None:
1008
+ """
1009
+ One SFTP request per CHANNEL_DATA message (real servers reassemble
1010
+ the stream by the length prefix; splitting breaks framing).
1011
+ Our requests stay under ~33KB; server windows are megabytes.
1012
+ """
1013
+ if self._t is None:
1014
+ raise CCSSHError("connection closed")
1015
+ if flow["window"] < len(data):
1016
+ _top_up(len(data))
1017
+ self._t.send_packet(CH.enc_data(_srv, data))
1018
+ flow["window"] -= len(data)
1019
+
1020
+ state = {"consumed": 0}
1021
+
1022
+ def _recv() -> bytes:
1023
+ if self._t is None:
1024
+ raise CCSSHError("connection closed")
1025
+ while True:
1026
+ if cell.get("eof"):
1027
+ raise SFTP.SFTPError("sftp: channel ended by server (EOF)")
1028
+ p = self._rx(chan)
1029
+ m = p[0]
1030
+ rr = W.Reader(p)
1031
+ rr.byte()
1032
+ if m == CH.MSG_CHANNEL_DATA:
1033
+ if rr.u32() != chan:
1034
+ continue
1035
+ chunk = rr.string()
1036
+ state["consumed"] += len(chunk)
1037
+ if state["consumed"] >= CH.WINDOW_THRESHOLD:
1038
+ if self._t is None:
1039
+ raise CCSSHError("connection closed")
1040
+ try:
1041
+ self._t.send_packet(CH.enc_window_adjust(srv, state["consumed"]))
1042
+ except Exception:
1043
+ pass # keep the credit AND the chunk; the next
1044
+ # successful send retries a larger adjust
1045
+ else:
1046
+ state["consumed"] = 0
1047
+ return chunk
1048
+ if m == CH.MSG_WINDOW_ADJUST:
1049
+ if rr.u32() != chan:
1050
+ continue
1051
+ try:
1052
+ flow["window"] = CH.check_window(flow["window"], rr.u32())
1053
+ except ValueError as e:
1054
+ raise SFTP.SFTPError(f"sftp: {e}") from e
1055
+ continue
1056
+ if m == CH.MSG_CHANNEL_EOF:
1057
+ if rr.u32() != chan:
1058
+ continue
1059
+ continue
1060
+ if m == CH.MSG_CHANNEL_CLOSE:
1061
+ try:
1062
+ if rr.u32() != chan:
1063
+ continue
1064
+ except Exception:
1065
+ pass
1066
+ raise SFTP.SFTPError("sftp: channel closed by server")
1067
+ if m == 20:
1068
+ raise SFTP.SFTPError("peer started rekey during SFTP: close and rekey()")
1069
+ continue
1070
+
1071
+ sc = SFTP.SFTPClient(_send, _recv, srv, maxpacket=srv_maxpacket)
1072
+ cell["sc"] = sc
1073
+ # INIT(3) -> VERSION (no request id on either side). The reply may
1074
+ # straddle CHANNEL_DATA messages, so accumulate like _call does.
1075
+ init = W.enc_u32(5) + bytes([SFTP.F_INIT]) + W.enc_u32(3)
1076
+ _send(srv, init)
1077
+ acc = b""
1078
+ deadline = time.monotonic() + self.timeout
1079
+ while True:
1080
+ if len(acc) >= 4:
1081
+ ln = int.from_bytes(acc[:4], "big")
1082
+ if ln > SFTP.SFTPClient.MAX_REPLY:
1083
+ raise SFTP.SFTPError(f"sftp: reply too big ({ln} bytes)")
1084
+ if len(acc) >= 4 + ln:
1085
+ break
1086
+ if len(acc) > SFTP.SFTPClient.MAX_REPLY + 4:
1087
+ raise SFTP.SFTPError("sftp: reply too big")
1088
+ if time.monotonic() > deadline:
1089
+ raise CCSSHError("sftp: version handshake timeout")
1090
+ acc += _recv()
1091
+ br = W.Reader(acc[4:4 + ln])
1092
+ if br.byte() != SFTP.F_VERSION or br.u32() > 3:
1093
+ raise CCSSHError("sftp: server did not offer version 3")
1094
+ sc._on_close = _make_closer(self, srv, chan)
1095
+ return sc
1096
+
1097
+ def negotiated(self) -> dict[str, str]:
1098
+ return {"kex": self._kex, "cipher": self._cipher, "mac": self._mac, "strict": str(self._strict), "hostkey": getattr(self, "_host_alg", "unknown")}
1099
+
1100
+ def _claim_forwarder(self, fwd):
1101
+ """
1102
+ Only one active forwarder per connection (kept as a deliberate
1103
+ v1 limit even though the mux could share more: two pumps would
1104
+ still split server-initiated opens ambiguously).
1105
+ """
1106
+ self._fwds = [f for f in self._fwds if not f.closed]
1107
+ if self._fwds:
1108
+ raise CCSSHError("only one active forwarder per connection")
1109
+ self._fwds.append(fwd)
1110
+ return fwd
1111
+
1112
+ def forward_local(self, lport: int, rhost: str, rport: int, bind: str = "127.0.0.1"):
1113
+ """
1114
+ Local forwarding (-L) in a background daemon thread.
1115
+
1116
+ Shares the connection with exec/sftp/shell through the channel
1117
+ multiplexer (ssh-like: no -N needed, though -N still works).
1118
+ Returns the LocalForwarder (.port, .close()).
1119
+ """
1120
+ if not self._t:
1121
+ raise CCSSHError("call .connect() first")
1122
+ mux = self._ensure_mux()
1123
+ self._log(1, f"forward: -L {bind}:{lport} -> {rhost}:{rport} (mux shared)")
1124
+ return self._claim_forwarder(
1125
+ _forward_mod.LocalForwarder(mux, lport, rhost, rport, bind=bind).start())
1126
+
1127
+ def forward_remote(self, sport: int, rhost: str, rport: int, bind: str = "localhost"):
1128
+ """
1129
+ Remote forwarding (-R) in a background daemon thread: the server
1130
+ listens on (bind, sport) - sport 0 means auto-assign, the real
1131
+ port lands in .port - and each inbound connection is relayed to
1132
+ (rhost, rport) on our side. The default bind is "localhost" like
1133
+ OpenSSH (some servers match it literally). Shares the connection
1134
+ through the channel multiplexer (ssh-like). Returns RemoteForwarder
1135
+ (.port, .close()).
1136
+ """
1137
+ if not self._t:
1138
+ raise CCSSHError("call .connect() first")
1139
+ mux = self._ensure_mux()
1140
+ self._log(1, f"forward: -R {bind}:{sport} -> {rhost}:{rport} (mux shared)")
1141
+ return self._claim_forwarder(
1142
+ _forward_mod.RemoteForwarder(mux, sport, rhost, rport, bind=bind).start())
1143
+
1144
+ def rekey(self) -> None:
1145
+ """
1146
+ Re-run KEX over the connection. session_id is kept, seq reset.
1147
+
1148
+ Call only with no open channels (between exec calls, forwarders
1149
+ closed). Strict-kex and ext-info markers are NOT repeated
1150
+ (Terrapin rule); no EXT_INFO wait.
1151
+ """
1152
+ if not self._t or not self._sid:
1153
+ raise CCSSHError("call .connect() first")
1154
+ if self._mux is not None and self._mux.busy():
1155
+ raise CCSSHError("rekey with open channels/forwards is unsupported: "
1156
+ "close channels and forwarders first")
1157
+ self._log(1, "rekey: starting ...")
1158
+ t = self._t
1159
+ mux = self._mux
1160
+ if mux is not None:
1161
+ # Park the mux reader: it must not be mid-read when the crypto
1162
+ # state below is swapped (old keys decrypt half a packet, new
1163
+ # keys the other half). No channels are open, so nothing else
1164
+ # reads or sends meanwhile.
1165
+ mux.pause()
1166
+ try:
1167
+ our = K0.enc_kexinit(os.urandom(16), self.kex_list, self.hostkey_list, self.cipher_list, self.cipher_list, self.mac_list, self.mac_list, ["none"], ["none"])
1168
+ t.send_packet(our)
1169
+ peer = K0.dec_kexinit(t.recv_packet())
1170
+ kex_name = K0.negotiate(self.kex_list, peer["kex"])
1171
+ cipher = K0.negotiate_symmetric(self.cipher_list, peer["enc_c2s"], peer["enc_s2c"], "cipher")
1172
+ mac = K0.negotiate_symmetric(self.mac_list, peer["mac_c2s"], peer["mac_s2c"], "mac")
1173
+ host_alg = K0.negotiate(self.hostkey_list, peer["hostkey"])
1174
+ Ic, Is = K0.I_FOR_H(our), K0.I_FOR_H(peer["payload_full"])
1175
+ self._skip_unmatched_guess(t, peer, self.kex_list, kex_name)
1176
+ K_enc, Hh2, Ks2, hashfn = self._run_kex(t, kex_name, VERSION, t.peer_version or VERSION, Ic, Is, host_alg)
1177
+ # The host key must not change across rekey (MITM during rekey).
1178
+ if self._host_Ks is not None and Ks2 != self._host_Ks:
1179
+ raise HostKeyError("host key changed during rekey (possible MitM!)")
1180
+ keys = self._derive(cipher, mac, K_enc, Hh2, hashfn, self._sid)
1181
+ from ccssh.transport import _encode_kind
1182
+ if _encode_kind(cipher) != _encode_kind(self._cipher) and self._cipher != "none":
1183
+ raise CCSSHError("rekey must not change the cipher kind "
1184
+ f"({self._cipher} -> {cipher})")
1185
+ t.stage_rekey(keys, cipher, mac, is_client=True)
1186
+ t.send_packet(bytes([K0.MSG_NEWKEYS])) # old keys
1187
+ t.commit_rekey_send(reset_seq=self._strict)
1188
+ t._enc_on_send = True
1189
+ if (t.recv_packet() or b"\x00")[0] != K0.MSG_NEWKEYS: # old keys
1190
+ raise CCSSHError("rekey: expected NEWKEYS")
1191
+ t.commit_rekey_recv(reset_seq=self._strict)
1192
+ t._enc_on_recv = True
1193
+ self._cipher, self._mac = cipher, mac
1194
+ self._kex = kex_name
1195
+ self._log(1, f"rekey: done kex={kex_name} cipher={cipher} mac={mac}")
1196
+ finally:
1197
+ if mux is not None:
1198
+ try:
1199
+ mux.resume()
1200
+ except Exception:
1201
+ pass
1202
+
1203
+ def close(self) -> None:
1204
+ for f in list(self._fwds):
1205
+ try:
1206
+ f.close()
1207
+ except Exception:
1208
+ pass
1209
+ try:
1210
+ th = getattr(f, "_th", None)
1211
+ if th is not None:
1212
+ th.join(timeout=2.0)
1213
+ except Exception:
1214
+ pass
1215
+ self._fwds = []
1216
+ try:
1217
+ if self._t:
1218
+ try:
1219
+ self._t.send_packet(bytes([1]) + W.enc_u32(11) + W.enc_string(b"bye") + W.enc_string(b""))
1220
+ except Exception:
1221
+ pass
1222
+ self._t.sock.close()
1223
+ except Exception:
1224
+ pass
1225
+ mux, self._mux = self._mux, None
1226
+ if mux is not None:
1227
+ try:
1228
+ mux.stop()
1229
+ except Exception:
1230
+ pass
1231
+ self._t = None
1232
+
1233
+ def __enter__(self) -> "CCSSHClient":
1234
+ return self
1235
+
1236
+ def __exit__(self, *_) -> None:
1237
+ self.close()
1238
+
1239
+
1240
+ def _make_closer(client: CCSSHClient, srv: int, chan: int):
1241
+ def _closer() -> None:
1242
+ try:
1243
+ if client._t is None:
1244
+ return
1245
+ try:
1246
+ client._t.send_packet(CH.enc_close(srv))
1247
+ except Exception:
1248
+ return
1249
+ deadline = time.monotonic() + client.timeout
1250
+ while True:
1251
+ if time.monotonic() > deadline:
1252
+ break
1253
+ try:
1254
+ if client._mux is not None:
1255
+ p = client._rx(chan, timeout=max(0.01, deadline - time.monotonic()))
1256
+ else:
1257
+ p = client._t.recv_packet(keepalive=False)
1258
+ except Exception:
1259
+ break
1260
+ if p and p[0] == CH.MSG_CHANNEL_CLOSE:
1261
+ try:
1262
+ rr = W.Reader(p)
1263
+ rr.byte()
1264
+ if rr.u32() != chan:
1265
+ continue
1266
+ except Exception:
1267
+ pass
1268
+ break
1269
+ finally:
1270
+ try:
1271
+ client._unreg(chan)
1272
+ except Exception:
1273
+ pass
1274
+ return _closer