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/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
|