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