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/async_client.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""Async SSH client over asyncio. Same protocol core as the sync client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from ccssh import auth as A
|
|
10
|
+
from ccssh import algs
|
|
11
|
+
from ccssh import channel as CH
|
|
12
|
+
from ccssh import hostkeys as HK
|
|
13
|
+
from ccssh import kex as K0
|
|
14
|
+
from ccssh import kex_ext as KX
|
|
15
|
+
from ccssh.client import (
|
|
16
|
+
VERSION,
|
|
17
|
+
AuthError,
|
|
18
|
+
HostKeyError,
|
|
19
|
+
CIPHER_DEFAULT,
|
|
20
|
+
MAC_DEFAULT,
|
|
21
|
+
HOSTKEY_DEFAULT,
|
|
22
|
+
_load_user_priv,
|
|
23
|
+
_user_blob,
|
|
24
|
+
)
|
|
25
|
+
from ccssh.hostcheck import check_hostkey, HostKeyError as _HostCheckError
|
|
26
|
+
from ccssh.transport import AsyncTransport, CCSSHError
|
|
27
|
+
from ccssh import wire as W
|
|
28
|
+
from ccssh.client import CCSSHClient as _SyncClient
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AsyncCCSSHClient:
|
|
32
|
+
def __init__(self, *, timeout: float = 10.0, host_policy: str = "strict", known_hosts: str | None = None, 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):
|
|
33
|
+
self.timeout = timeout
|
|
34
|
+
self.host_policy = host_policy
|
|
35
|
+
self.known_hosts = os.path.expanduser(known_hosts) if known_hosts else None
|
|
36
|
+
self.kex_list = algs.normalize_list(algs.SUPPORTED_KEX, kex) or algs.default_kex()
|
|
37
|
+
self.cipher_list = algs.normalize_list(algs.SUPPORTED_CIPHERS, ciphers) or CIPHER_DEFAULT
|
|
38
|
+
self.mac_list = algs.normalize_list(algs.SUPPORTED_MACS, macs) or MAC_DEFAULT
|
|
39
|
+
self.hostkey_list = algs.normalize_list(algs.SUPPORTED_HOSTKEYS, hostkeys) or HOSTKEY_DEFAULT
|
|
40
|
+
self.rekey_bytes = rekey_bytes
|
|
41
|
+
self.rekey_interval = rekey_interval
|
|
42
|
+
self._t: AsyncTransport | None = None
|
|
43
|
+
self._sid: bytes | None = None
|
|
44
|
+
self._cipher = "none"
|
|
45
|
+
self._kex_name = "none"
|
|
46
|
+
self._mac = "none"
|
|
47
|
+
self._chan_next = 1
|
|
48
|
+
self._strict = False
|
|
49
|
+
self._host_Ks: bytes | None = None
|
|
50
|
+
self._open_channels = 0
|
|
51
|
+
# One channel operation at a time: concurrent readers would steal
|
|
52
|
+
# each other's packets (same single-reader rule as the sync side
|
|
53
|
+
# without a mux). Rekey takes the same lock.
|
|
54
|
+
self._op_lock = asyncio.Lock()
|
|
55
|
+
|
|
56
|
+
async def _maybe_rekey(self) -> None:
|
|
57
|
+
t = self._t
|
|
58
|
+
if t is None or self._sid is None:
|
|
59
|
+
return
|
|
60
|
+
if (time.monotonic() - t.kex_at > self.rekey_interval
|
|
61
|
+
or t.bytes_out + t.bytes_in > self.rekey_bytes):
|
|
62
|
+
await self.rekey()
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
async def _skip_unmatched_guess(t, peer: dict, our_kex: list[str], kex_name: str) -> None:
|
|
66
|
+
"""RFC 4253 section 7.1 decoy discard (async twin of the sync guard)."""
|
|
67
|
+
if not peer.get("first_follows"):
|
|
68
|
+
return
|
|
69
|
+
flags = {K0.KEX_STRICT_C, K0.KEX_STRICT_S, K0.EXT_INFO_C}
|
|
70
|
+
guess = next((k for k in peer["kex"] if k in our_kex and k not in flags), None)
|
|
71
|
+
if guess != kex_name:
|
|
72
|
+
await t.recv_packet() # discard the decoy
|
|
73
|
+
|
|
74
|
+
async def rekey(self) -> None:
|
|
75
|
+
"""Re-run KEX (sync semantics). Only with no open channels."""
|
|
76
|
+
if self._t is None or self._sid is None:
|
|
77
|
+
raise CCSSHError("call .connect() first")
|
|
78
|
+
if self._open_channels:
|
|
79
|
+
raise CCSSHError("rekey with open channels is unsupported: close channels first")
|
|
80
|
+
async with self._op_lock:
|
|
81
|
+
await self._rekey_inner()
|
|
82
|
+
|
|
83
|
+
async def _rekey_inner(self) -> None:
|
|
84
|
+
t = self._t
|
|
85
|
+
assert t is not None
|
|
86
|
+
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"])
|
|
87
|
+
await t.send_packet(our)
|
|
88
|
+
peer = K0.dec_kexinit(await t.recv_packet())
|
|
89
|
+
kex_name = K0.negotiate(self.kex_list, peer["kex"])
|
|
90
|
+
cipher = K0.negotiate_symmetric(self.cipher_list, peer["enc_c2s"], peer["enc_s2c"], "cipher")
|
|
91
|
+
mac = K0.negotiate_symmetric(self.mac_list, peer["mac_c2s"], peer["mac_s2c"], "mac")
|
|
92
|
+
host_alg = K0.negotiate(self.hostkey_list, peer["hostkey"])
|
|
93
|
+
Ic, Is = K0.I_FOR_H(our), K0.I_FOR_H(peer["payload_full"])
|
|
94
|
+
await self._skip_unmatched_guess(t, peer, self.kex_list, kex_name)
|
|
95
|
+
K_enc, Hh2, Ks2, hashfn = await self._kex(t, kex_name, VERSION, t.peer_version or VERSION, Ic, Is, host_alg)
|
|
96
|
+
if self._host_Ks is not None and Ks2 != self._host_Ks:
|
|
97
|
+
from ccssh.client import HostKeyError as _HKE2
|
|
98
|
+
raise _HKE2("host key changed during rekey (possible MitM!)")
|
|
99
|
+
keys = _SyncClient._derive(cipher, mac, K_enc, Hh2, hashfn, self._sid)
|
|
100
|
+
from ccssh.transport import _encode_kind
|
|
101
|
+
if _encode_kind(cipher) != _encode_kind(self._cipher) and self._cipher != "none":
|
|
102
|
+
raise CCSSHError(f"rekey must not change the cipher kind ({self._cipher} -> {cipher})")
|
|
103
|
+
t.stage_rekey(keys, cipher, mac, is_client=True)
|
|
104
|
+
await t.send_packet(bytes([K0.MSG_NEWKEYS]))
|
|
105
|
+
t.commit_rekey_send(reset_seq=self._strict)
|
|
106
|
+
t._enc_on_send = True
|
|
107
|
+
if (await t.recv_packet() or b"\x00")[0] != K0.MSG_NEWKEYS:
|
|
108
|
+
raise CCSSHError("rekey: expected NEWKEYS")
|
|
109
|
+
t.commit_rekey_recv(reset_seq=self._strict)
|
|
110
|
+
t._enc_on_recv = True
|
|
111
|
+
self._cipher, self._mac = cipher, mac
|
|
112
|
+
self._kex_name = kex_name
|
|
113
|
+
|
|
114
|
+
async 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, kbdint_responder: A.KbdintResponder | None = None) -> "AsyncCCSSHClient":
|
|
115
|
+
reader, writer = await self._open(hostname, port, family)
|
|
116
|
+
t = AsyncTransport(reader, writer, VERSION, timeout=self.timeout)
|
|
117
|
+
Vs = await t.do_version()
|
|
118
|
+
Vc = VERSION
|
|
119
|
+
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"])
|
|
120
|
+
await t.send_packet(our)
|
|
121
|
+
peer = K0.dec_kexinit(await t.recv_packet())
|
|
122
|
+
kex_name = K0.negotiate(self.kex_list, peer["kex"])
|
|
123
|
+
host_alg = K0.negotiate(self.hostkey_list, peer["hostkey"])
|
|
124
|
+
cipher = K0.negotiate_symmetric(self.cipher_list, peer["enc_c2s"], peer["enc_s2c"], "cipher")
|
|
125
|
+
mac = K0.negotiate_symmetric(self.mac_list, peer["mac_c2s"], peer["mac_s2c"], "mac")
|
|
126
|
+
Ic, Is = K0.I_FOR_H(our), K0.I_FOR_H(peer["payload_full"])
|
|
127
|
+
await self._skip_unmatched_guess(t, peer, self.kex_list, kex_name)
|
|
128
|
+
K_enc, Hh, Ks, hashfn = await self._kex(t, kex_name, Vc, Vs, Ic, Is, host_alg)
|
|
129
|
+
try:
|
|
130
|
+
check_hostkey(hostname, port, Ks, host_alg, policy=self.host_policy, known_hosts=self.known_hosts)
|
|
131
|
+
except _HostCheckError as e:
|
|
132
|
+
raise HostKeyError(str(e)) from e
|
|
133
|
+
keys = _SyncClient._derive(cipher, mac, K_enc, Hh, hashfn)
|
|
134
|
+
t.activate(keys, cipher, mac, is_client=True)
|
|
135
|
+
await t.send_packet(bytes([K0.MSG_NEWKEYS]))
|
|
136
|
+
t._enc_on_send = True
|
|
137
|
+
if (await t.recv_packet() or b"\x00")[0] != K0.MSG_NEWKEYS:
|
|
138
|
+
raise CCSSHError("expected NEWKEYS")
|
|
139
|
+
t._enc_on_recv = True
|
|
140
|
+
self._strict = K0.peer_wants_strict(peer["kex"], is_client=True)
|
|
141
|
+
if self._strict:
|
|
142
|
+
t.send_seq = 0
|
|
143
|
+
t.recv_seq = 0
|
|
144
|
+
self._t, self._sid = t, Hh
|
|
145
|
+
self._host_Ks = Ks
|
|
146
|
+
await t.send_packet(A.enc_service_request())
|
|
147
|
+
for _ in range(16): # EXT_INFO repeats are skipped, not fatal
|
|
148
|
+
pkt = await t.recv_packet()
|
|
149
|
+
if pkt and pkt[0] == K0.MSG_EXT_INFO:
|
|
150
|
+
continue
|
|
151
|
+
break
|
|
152
|
+
if not pkt or pkt[0] != A.MSG_SERVICE_ACCEPT:
|
|
153
|
+
raise CCSSHError("no ssh-userauth service")
|
|
154
|
+
if key_filename or cert_filename:
|
|
155
|
+
await self._auth_pubkey(username, key_filename, cert_filename, passphrase)
|
|
156
|
+
elif password is not None:
|
|
157
|
+
await t.send_packet(A.enc_password_request(username, password))
|
|
158
|
+
if (await t.recv_packet() or b"\x00")[0] != A.MSG_USERAUTH_SUCCESS:
|
|
159
|
+
try:
|
|
160
|
+
await self._auth_kbdint(username, lambda prompts: [password] * len(prompts))
|
|
161
|
+
except AuthError:
|
|
162
|
+
raise AuthError("password rejected") from None
|
|
163
|
+
elif kbdint_responder is not None:
|
|
164
|
+
await self._auth_kbdint(username, kbdint_responder)
|
|
165
|
+
else:
|
|
166
|
+
raise AuthError("provide password or key_filename")
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
async def _open(self, hostname: str, port: int, family: int):
|
|
170
|
+
"""
|
|
171
|
+
Connect honoring the address family (asyncio.open_connection has
|
|
172
|
+
no family knob, so dial manually and wrap the socket). DNS via
|
|
173
|
+
the loop (never blocks it).
|
|
174
|
+
"""
|
|
175
|
+
loop = asyncio.get_running_loop()
|
|
176
|
+
try:
|
|
177
|
+
infos = await loop.getaddrinfo(hostname, port, family=family or 0, type=socket.SOCK_STREAM)
|
|
178
|
+
except OSError as e:
|
|
179
|
+
raise CCSSHError(f"resolve {hostname}: {e}") from e
|
|
180
|
+
if not infos:
|
|
181
|
+
raise CCSSHError(f"no addresses for {hostname}")
|
|
182
|
+
last_err: OSError | None = None
|
|
183
|
+
s: socket.socket | None = None
|
|
184
|
+
for af, st, proto, _canon, sa in infos:
|
|
185
|
+
s = None
|
|
186
|
+
try:
|
|
187
|
+
s = socket.socket(af, st, proto)
|
|
188
|
+
s.setblocking(False)
|
|
189
|
+
await asyncio.wait_for(loop.sock_connect(s, sa), self.timeout)
|
|
190
|
+
except OSError as e:
|
|
191
|
+
last_err = e
|
|
192
|
+
if s is not None:
|
|
193
|
+
s.close()
|
|
194
|
+
s = None
|
|
195
|
+
continue
|
|
196
|
+
break
|
|
197
|
+
if s is None:
|
|
198
|
+
raise CCSSHError(f"connect {hostname}:{port}: {last_err}")
|
|
199
|
+
try:
|
|
200
|
+
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
201
|
+
except Exception:
|
|
202
|
+
pass
|
|
203
|
+
return await asyncio.open_connection(sock=s)
|
|
204
|
+
|
|
205
|
+
async def _kex(self, t: AsyncTransport, name: str, Vc: bytes, Vs: bytes, Ic: bytes, Is: bytes, host_alg: str = "ssh-ed25519"):
|
|
206
|
+
name = KX.KEX_ALIASES.get(name, name)
|
|
207
|
+
hashfn = KX.kdf_hashfn(name)
|
|
208
|
+
if name == "curve25519-sha256":
|
|
209
|
+
c = K0.Curve25519Client()
|
|
210
|
+
await t.send_packet(c.init_msg())
|
|
211
|
+
Ke, Hh, Ks = c.finish(await t.recv_packet(), Vc, Vs, Ic, Is, host_alg)
|
|
212
|
+
return W.mpint_from_bytes(Ke), Hh, Ks, hashfn
|
|
213
|
+
if name == "mlkem768x25519-sha256":
|
|
214
|
+
c = K0.MlkemHybridClient(Vc, Ic)
|
|
215
|
+
await t.send_packet(c.init_msg())
|
|
216
|
+
Kr, Hh, Ks, _, _ = c.finish(await t.recv_packet(), Vs, Is, host_alg)
|
|
217
|
+
return W.enc_string(Kr), Hh, Ks, hashfn
|
|
218
|
+
if name.startswith("ecdh-sha2-nistp"):
|
|
219
|
+
c = KX.EcdhClient(name)
|
|
220
|
+
await t.send_packet(c.init_msg())
|
|
221
|
+
Ke, Hh, Ks = c.finish(await t.recv_packet(), Vc, Vs, Ic, Is, host_alg)
|
|
222
|
+
return W.mpint_from_bytes(Ke), Hh, Ks, hashfn
|
|
223
|
+
if name in KX.DH_GROUPS:
|
|
224
|
+
c = KX.DhClient(name)
|
|
225
|
+
await t.send_packet(c.init_msg())
|
|
226
|
+
Kr, Hh, Ks = c.finish(await t.recv_packet(), Vc, Vs, Ic, Is, host_alg)
|
|
227
|
+
return W.mpint_from_bytes(Kr), Hh, Ks, hashfn
|
|
228
|
+
if name == "mlkem768nistp256-sha256":
|
|
229
|
+
c = KX.MlkemP256Client(Vc, Ic)
|
|
230
|
+
await t.send_packet(c.init_msg())
|
|
231
|
+
Kr, Hh, Ks = c.finish(await t.recv_packet(), Vs, Is, host_alg)
|
|
232
|
+
return W.enc_string(Kr), Hh, Ks, hashfn
|
|
233
|
+
if name in ("diffie-hellman-group-exchange-sha256", "diffie-hellman-group-exchange-sha1"):
|
|
234
|
+
c = KX.GexClient(hashfn=hashlib.sha256 if name.endswith("sha256") else hashlib.sha1)
|
|
235
|
+
await t.send_packet(c.request_msg())
|
|
236
|
+
await t.send_packet(c.init_msg(await t.recv_packet()))
|
|
237
|
+
Kr, Hh, Ks = c.finish(await t.recv_packet(), Vc, Vs, Ic, Is, host_alg)
|
|
238
|
+
return W.mpint_from_bytes(Kr), Hh, Ks, hashfn
|
|
239
|
+
if name == "sntrup761x25519-sha512":
|
|
240
|
+
c = KX.SntrupClient(Vc, Ic)
|
|
241
|
+
await t.send_packet(c.init_msg())
|
|
242
|
+
Kr, Hh, Ks, _, _ = c.finish(await t.recv_packet(), Vs, Is, host_alg)
|
|
243
|
+
return W.enc_string(Kr), Hh, Ks, hashfn
|
|
244
|
+
raise CCSSHError(name)
|
|
245
|
+
|
|
246
|
+
async def _auth_pubkey(self, username: str, key_path: str | None, cert_path: str | None, passphrase: str | None) -> None:
|
|
247
|
+
if not self._t or self._sid is None:
|
|
248
|
+
raise CCSSHError("call .connect() first")
|
|
249
|
+
if not key_path:
|
|
250
|
+
raise AuthError("cert without key_filename is unsupported")
|
|
251
|
+
priv = _load_user_priv(key_path, passphrase)
|
|
252
|
+
alg, blob = _user_blob(priv, cert_path)
|
|
253
|
+
if blob is None:
|
|
254
|
+
nums = priv.public_key().public_numbers()
|
|
255
|
+
blob = W.enc_string(b"ssh-rsa") + W.enc_mpint(nums.e) + W.enc_mpint(nums.n)
|
|
256
|
+
alg = b"rsa-sha2-256"
|
|
257
|
+
await self._t.send_packet(A.enc_pubkey_probe(username, alg, blob))
|
|
258
|
+
if (await self._t.recv_packet() or b"\x00")[0] == A.MSG_USERAUTH_SUCCESS:
|
|
259
|
+
return
|
|
260
|
+
outer = HK.sign_userauth(priv, alg.decode(), A.auth_sign_blob(self._sid, username, alg, blob))
|
|
261
|
+
await self._t.send_packet(A.enc_pubkey_signed(username, alg, blob, outer))
|
|
262
|
+
if (await self._t.recv_packet() or b"\x00")[0] != A.MSG_USERAUTH_SUCCESS:
|
|
263
|
+
raise AuthError("publickey rejected")
|
|
264
|
+
|
|
265
|
+
async def _auth_kbdint(self, username: str, responder: A.KbdintResponder) -> None:
|
|
266
|
+
"""Keyboard-interactive auth (RFC 4256), async twin of the sync one."""
|
|
267
|
+
t = self._t
|
|
268
|
+
if t is None:
|
|
269
|
+
raise CCSSHError("call .connect() first")
|
|
270
|
+
await t.send_packet(A.enc_kbdint_probe(username))
|
|
271
|
+
for _ in range(A.MAX_KBDINT_ROUNDS):
|
|
272
|
+
p = await t.recv_packet() or b"\x00"
|
|
273
|
+
m = p[0]
|
|
274
|
+
if m == A.MSG_USERAUTH_SUCCESS:
|
|
275
|
+
return
|
|
276
|
+
if m == A.MSG_USERAUTH_FAILURE:
|
|
277
|
+
raise AuthError("keyboard-interactive rejected")
|
|
278
|
+
if m != A.MSG_USERAUTH_INFO_REQUEST:
|
|
279
|
+
raise AuthError(f"keyboard-interactive: unexpected reply {m}")
|
|
280
|
+
try:
|
|
281
|
+
info = A.dec_info_request(p)
|
|
282
|
+
except W.WireError as e:
|
|
283
|
+
raise AuthError(f"keyboard-interactive: bad prompt: {e}") from e
|
|
284
|
+
try:
|
|
285
|
+
answers = responder(info["prompts"])
|
|
286
|
+
except (KeyboardInterrupt, SystemExit):
|
|
287
|
+
raise
|
|
288
|
+
except Exception as e:
|
|
289
|
+
raise AuthError(f"keyboard-interactive responder failed: {e}") from e
|
|
290
|
+
if len(answers) != len(info["prompts"]):
|
|
291
|
+
raise AuthError("keyboard-interactive responder gave wrong answer count")
|
|
292
|
+
await t.send_packet(A.enc_info_response([a.encode() for a in answers]))
|
|
293
|
+
raise AuthError("keyboard-interactive rejected (too many rounds)")
|
|
294
|
+
|
|
295
|
+
async def exec_command(self, command: str, *, timeout: float | None = None, max_bytes: int = 16 * 1024 * 1024, raw_bytes: bool = False):
|
|
296
|
+
"""One exec channel at a time (see _op_lock): concurrent readers
|
|
297
|
+
would steal each other's packets on the shared transport."""
|
|
298
|
+
if self._t is None:
|
|
299
|
+
raise CCSSHError("call .connect() first")
|
|
300
|
+
async with self._op_lock:
|
|
301
|
+
out_b, err_b, code = await self._exec_inner(command, timeout=timeout, max_bytes=max_bytes)
|
|
302
|
+
if raw_bytes:
|
|
303
|
+
return out_b, err_b, code
|
|
304
|
+
return out_b.decode(errors="replace"), err_b.decode(errors="replace"), code
|
|
305
|
+
|
|
306
|
+
async def _exec_inner(self, command: str, *, timeout: float | None, max_bytes: int) -> tuple[str, str, int]:
|
|
307
|
+
_ = timeout # accepted for sync parity; the transport timeout applies
|
|
308
|
+
await self._maybe_rekey()
|
|
309
|
+
t = self._t
|
|
310
|
+
assert t is not None
|
|
311
|
+
chan = self._chan_next
|
|
312
|
+
self._chan_next += 1
|
|
313
|
+
self._open_channels += 1
|
|
314
|
+
try:
|
|
315
|
+
await t.send_packet(CH.enc_open_session(chan))
|
|
316
|
+
while True:
|
|
317
|
+
r = W.Reader(await t.recv_packet())
|
|
318
|
+
m0 = r.byte()
|
|
319
|
+
if m0 == CH.MSG_CHANNEL_OPEN_CONFIRM:
|
|
320
|
+
break
|
|
321
|
+
if m0 == CH.MSG_CHANNEL_OPEN_FAILURE:
|
|
322
|
+
raise CCSSHError("channel refused")
|
|
323
|
+
continue
|
|
324
|
+
if r.u32() != chan:
|
|
325
|
+
raise CCSSHError("channel confirm for foreign channel")
|
|
326
|
+
srv = r.u32()
|
|
327
|
+
await t.send_packet(CH.enc_exec(srv, command))
|
|
328
|
+
while True:
|
|
329
|
+
pkt = await t.recv_packet() or b"\x00"
|
|
330
|
+
if pkt[0] == CH.MSG_CHANNEL_SUCCESS:
|
|
331
|
+
break
|
|
332
|
+
if pkt[0] == CH.MSG_CHANNEL_FAILURE:
|
|
333
|
+
raise CCSSHError("exec refused")
|
|
334
|
+
continue
|
|
335
|
+
out_parts: list[bytes] = []
|
|
336
|
+
err_parts: list[bytes] = []
|
|
337
|
+
out_len = err_len = 0
|
|
338
|
+
code = 0
|
|
339
|
+
consumed = 0
|
|
340
|
+
while True:
|
|
341
|
+
p = await t.recv_packet() or b"\x00"
|
|
342
|
+
m = p[0]
|
|
343
|
+
rr = W.Reader(p)
|
|
344
|
+
rr.byte()
|
|
345
|
+
if m == CH.MSG_CHANNEL_DATA:
|
|
346
|
+
if rr.u32() != chan:
|
|
347
|
+
continue
|
|
348
|
+
chunk = rr.string()
|
|
349
|
+
out_len += len(chunk)
|
|
350
|
+
if out_len + err_len > max_bytes:
|
|
351
|
+
raise CCSSHError("exec output exceeds max_bytes")
|
|
352
|
+
out_parts.append(chunk)
|
|
353
|
+
consumed += len(chunk)
|
|
354
|
+
elif m == CH.MSG_CHANNEL_EXTENDED_DATA:
|
|
355
|
+
if rr.u32() != chan:
|
|
356
|
+
continue
|
|
357
|
+
if rr.u32() != 1:
|
|
358
|
+
continue
|
|
359
|
+
chunk = rr.string()
|
|
360
|
+
err_len += len(chunk)
|
|
361
|
+
if out_len + err_len > max_bytes:
|
|
362
|
+
raise CCSSHError("exec output exceeds max_bytes")
|
|
363
|
+
err_parts.append(chunk)
|
|
364
|
+
consumed += len(chunk)
|
|
365
|
+
elif m == CH.MSG_CHANNEL_REQUEST:
|
|
366
|
+
if rr.u32() != chan:
|
|
367
|
+
continue
|
|
368
|
+
if rr.string() == b"exit-status":
|
|
369
|
+
rr.bool()
|
|
370
|
+
code = rr.u32()
|
|
371
|
+
elif m == 20: # peer-initiated rekey needs idle channels
|
|
372
|
+
raise CCSSHError("peer started rekey with an open channel: close channels and call rekey()")
|
|
373
|
+
elif m == CH.MSG_CHANNEL_CLOSE:
|
|
374
|
+
try:
|
|
375
|
+
rr2 = W.Reader(p)
|
|
376
|
+
rr2.byte()
|
|
377
|
+
if rr2.u32() != chan:
|
|
378
|
+
continue
|
|
379
|
+
except Exception:
|
|
380
|
+
pass
|
|
381
|
+
try:
|
|
382
|
+
await t.send_packet(CH.enc_close(srv))
|
|
383
|
+
except Exception:
|
|
384
|
+
pass
|
|
385
|
+
break
|
|
386
|
+
if consumed >= CH.WINDOW_THRESHOLD:
|
|
387
|
+
try:
|
|
388
|
+
await t.send_packet(CH.enc_window_adjust(srv, consumed))
|
|
389
|
+
except Exception:
|
|
390
|
+
pass
|
|
391
|
+
else:
|
|
392
|
+
consumed = 0
|
|
393
|
+
out = b"".join(out_parts)
|
|
394
|
+
err = b"".join(err_parts)
|
|
395
|
+
return out, err, code
|
|
396
|
+
finally:
|
|
397
|
+
self._open_channels -= 1
|
|
398
|
+
|
|
399
|
+
async def close(self) -> None:
|
|
400
|
+
try:
|
|
401
|
+
if self._t:
|
|
402
|
+
try:
|
|
403
|
+
await self._t.send_packet(bytes([1]) + W.enc_u32(11) + W.enc_string(b"bye") + W.enc_string(b""))
|
|
404
|
+
except Exception:
|
|
405
|
+
pass
|
|
406
|
+
try:
|
|
407
|
+
self._t.writer.close()
|
|
408
|
+
await asyncio.wait_for(self._t.writer.wait_closed(), 2.0)
|
|
409
|
+
except Exception:
|
|
410
|
+
pass
|
|
411
|
+
except Exception:
|
|
412
|
+
pass
|
|
413
|
+
self._t = None
|
|
414
|
+
|
|
415
|
+
async def __aenter__(self) -> "AsyncCCSSHClient":
|
|
416
|
+
return self
|
|
417
|
+
|
|
418
|
+
async def __aexit__(self, *_) -> None:
|
|
419
|
+
await self.close()
|
ccssh/auth.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
User authentication messages, RFC 4252: service request, password and
|
|
3
|
+
publickey methods (probe + signed), keyboard-interactive (RFC 4256),
|
|
4
|
+
plus a request parser for the server side.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
from ccssh import wire as W
|
|
11
|
+
|
|
12
|
+
MSG_SERVICE_REQUEST = 5
|
|
13
|
+
MSG_SERVICE_ACCEPT = 6
|
|
14
|
+
MSG_USERAUTH_REQUEST = 50
|
|
15
|
+
MSG_USERAUTH_FAILURE = 51
|
|
16
|
+
MSG_USERAUTH_SUCCESS = 52
|
|
17
|
+
MSG_USERAUTH_PK_OK = 60
|
|
18
|
+
MSG_USERAUTH_INFO_REQUEST = 60 # same number as PK_OK: context disambiguates
|
|
19
|
+
MSG_USERAUTH_INFO_RESPONSE = 61
|
|
20
|
+
|
|
21
|
+
# DoS caps for keyboard-interactive (a rogue server prompts forever).
|
|
22
|
+
MAX_KBDINT_PROMPTS = 16
|
|
23
|
+
MAX_KBDINT_ROUNDS = 5
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def enc_service_request(service: bytes = b"ssh-userauth") -> bytes:
|
|
27
|
+
return bytes([MSG_SERVICE_REQUEST]) + W.enc_string(service)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def enc_password_request(user: str, password: str, *, service: str = "ssh-connection") -> bytes:
|
|
31
|
+
if len(user.encode()) > 255 or len(password.encode()) > 1024:
|
|
32
|
+
raise ValueError("user/password too long")
|
|
33
|
+
if len(service.encode()) > 255:
|
|
34
|
+
raise ValueError("service name too long")
|
|
35
|
+
return (bytes([MSG_USERAUTH_REQUEST])
|
|
36
|
+
+ W.enc_string(user.encode())
|
|
37
|
+
+ W.enc_string(service.encode())
|
|
38
|
+
+ W.enc_string(b"password")
|
|
39
|
+
+ W.enc_bool(False)
|
|
40
|
+
+ W.enc_string(password.encode()))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def enc_pubkey_probe(user: str, alg: bytes, key_blob: bytes, *, service: str = "ssh-connection") -> bytes:
|
|
44
|
+
if len(user.encode()) > 255 or len(key_blob) > 16384:
|
|
45
|
+
raise ValueError("pubkey probe too large")
|
|
46
|
+
return (bytes([MSG_USERAUTH_REQUEST])
|
|
47
|
+
+ W.enc_string(user.encode())
|
|
48
|
+
+ W.enc_string(service.encode())
|
|
49
|
+
+ W.enc_string(b"publickey")
|
|
50
|
+
+ W.enc_bool(False)
|
|
51
|
+
+ W.enc_string(alg)
|
|
52
|
+
+ W.enc_string(key_blob))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def enc_pubkey_signed(user: str, alg: bytes, key_blob: bytes, signature_nested: bytes, *, service: str = "ssh-connection") -> bytes:
|
|
56
|
+
"""
|
|
57
|
+
Assemble a signed publickey request.
|
|
58
|
+
|
|
59
|
+
``signature_nested`` is the sshkey_sign output buffer, i.e.
|
|
60
|
+
string(alg) + string(signature); it is wrapped here into the
|
|
61
|
+
signature string field.
|
|
62
|
+
"""
|
|
63
|
+
return (bytes([MSG_USERAUTH_REQUEST])
|
|
64
|
+
+ W.enc_string(user.encode())
|
|
65
|
+
+ W.enc_string(service.encode())
|
|
66
|
+
+ W.enc_string(b"publickey")
|
|
67
|
+
+ W.enc_bool(True)
|
|
68
|
+
+ W.enc_string(alg)
|
|
69
|
+
+ W.enc_string(key_blob)
|
|
70
|
+
+ W.enc_string(signature_nested))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def auth_sign_blob(session_id: bytes, user: str, alg: bytes, key_blob: bytes, *, service: str = "ssh-connection") -> bytes:
|
|
74
|
+
"""Data signed by the user's private key (RFC 4252 section 7)."""
|
|
75
|
+
return (W.enc_string(session_id)
|
|
76
|
+
+ bytes([MSG_USERAUTH_REQUEST])
|
|
77
|
+
+ W.enc_string(user.encode())
|
|
78
|
+
+ W.enc_string(service.encode())
|
|
79
|
+
+ W.enc_string(b"publickey")
|
|
80
|
+
+ W.enc_bool(True)
|
|
81
|
+
+ W.enc_string(alg)
|
|
82
|
+
+ W.enc_string(key_blob))
|
|
83
|
+
|
|
84
|
+
def dec_userauth_request(payload: bytes) -> dict:
|
|
85
|
+
r = W.Reader(payload)
|
|
86
|
+
if r.byte() != MSG_USERAUTH_REQUEST:
|
|
87
|
+
raise W.WireError("expected SSH_MSG_USERAUTH_REQUEST")
|
|
88
|
+
try:
|
|
89
|
+
user = r.string().decode(errors="replace")
|
|
90
|
+
service = r.string().decode(errors="replace")
|
|
91
|
+
method = r.string().decode(errors="replace")
|
|
92
|
+
except W.WireError:
|
|
93
|
+
raise
|
|
94
|
+
return {"user": user, "service": service, "method": method, "rest": payload[r.tell():]}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def enc_kbdint_probe(user: str, *, service: str = "ssh-connection") -> bytes:
|
|
98
|
+
"""Start keyboard-interactive auth (RFC 4256 section 3.2)."""
|
|
99
|
+
if len(user.encode()) > 255 or len(service.encode()) > 255:
|
|
100
|
+
raise ValueError("user/service name too long")
|
|
101
|
+
return (bytes([MSG_USERAUTH_REQUEST])
|
|
102
|
+
+ W.enc_string(user.encode())
|
|
103
|
+
+ W.enc_string(service.encode())
|
|
104
|
+
+ W.enc_string(b"keyboard-interactive")
|
|
105
|
+
+ W.enc_string(b"")
|
|
106
|
+
+ W.enc_string(b""))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def dec_info_request(payload: bytes) -> dict:
|
|
110
|
+
"""Parse SSH_MSG_USERAUTH_INFO_REQUEST (RFC 4256 section 3.4)."""
|
|
111
|
+
r = W.Reader(payload)
|
|
112
|
+
if r.byte() != MSG_USERAUTH_INFO_REQUEST:
|
|
113
|
+
raise W.WireError("expected SSH_MSG_USERAUTH_INFO_REQUEST")
|
|
114
|
+
try:
|
|
115
|
+
name = r.string().decode(errors="replace")
|
|
116
|
+
instruction = r.string().decode(errors="replace")
|
|
117
|
+
_lang = r.string()
|
|
118
|
+
n = r.u32()
|
|
119
|
+
except W.WireError:
|
|
120
|
+
raise
|
|
121
|
+
if n > MAX_KBDINT_PROMPTS:
|
|
122
|
+
raise W.WireError(f"too many prompts ({n})")
|
|
123
|
+
prompts: list[tuple[str, bool]] = []
|
|
124
|
+
try:
|
|
125
|
+
for _ in range(n):
|
|
126
|
+
prompts.append((r.string().decode(errors="replace"), r.bool()))
|
|
127
|
+
except W.WireError:
|
|
128
|
+
raise
|
|
129
|
+
r.assert_end()
|
|
130
|
+
return {"name": name, "instruction": instruction, "prompts": prompts}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def enc_info_response(responses: list[bytes]) -> bytes:
|
|
134
|
+
"""Assemble SSH_MSG_USERAUTH_INFO_RESPONSE (RFC 4256 section 3.5)."""
|
|
135
|
+
if len(responses) > MAX_KBDINT_PROMPTS:
|
|
136
|
+
raise ValueError(f"too many responses ({len(responses)})")
|
|
137
|
+
out = bytes([MSG_USERAUTH_INFO_RESPONSE]) + W.enc_u32(len(responses))
|
|
138
|
+
for resp in responses:
|
|
139
|
+
if len(resp) > 1024:
|
|
140
|
+
raise ValueError("response too long")
|
|
141
|
+
out += W.enc_string(resp)
|
|
142
|
+
return out
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def dec_info_response(payload: bytes) -> list[bytes]:
|
|
146
|
+
"""Parse SSH_MSG_USERAUTH_INFO_RESPONSE (server/test side)."""
|
|
147
|
+
r = W.Reader(payload)
|
|
148
|
+
if r.byte() != MSG_USERAUTH_INFO_RESPONSE:
|
|
149
|
+
raise W.WireError("expected SSH_MSG_USERAUTH_INFO_RESPONSE")
|
|
150
|
+
n = r.u32()
|
|
151
|
+
if n > MAX_KBDINT_PROMPTS:
|
|
152
|
+
raise W.WireError(f"too many responses ({n})")
|
|
153
|
+
out = [r.string() for _ in range(n)]
|
|
154
|
+
r.assert_end()
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def dec_failure(payload: bytes) -> dict:
|
|
159
|
+
"""Parse SSH_MSG_USERAUTH_FAILURE: remaining methods + partial flag."""
|
|
160
|
+
r = W.Reader(payload)
|
|
161
|
+
if r.byte() != MSG_USERAUTH_FAILURE:
|
|
162
|
+
raise W.WireError("expected SSH_MSG_USERAUTH_FAILURE")
|
|
163
|
+
methods = r.namelist()
|
|
164
|
+
partial = r.bool()
|
|
165
|
+
r.assert_end()
|
|
166
|
+
return {"methods": methods, "partial": partial}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# Responder: server prompts [(text, echo)] -> answers. May raise to abort.
|
|
170
|
+
KbdintResponder = Callable[[list[tuple[str, bool]]], list[str]]
|