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/transport.py ADDED
@@ -0,0 +1,737 @@
1
+ """
2
+ SSH binary packet transports: blocking sockets (sync) and asyncio (async).
3
+
4
+ Cipher coverage: AES-CTR with HMAC (plain and -etm), AES-GCM (RFC 5647),
5
+ chacha20-poly1305 (IETF draft). Framing notes:
6
+
7
+ * non-ETM CTR encrypts length + payload; MAC over sequence + plaintext.
8
+ * ETM (see OpenSSH packet.c, aadlen=4): length travels in the CLEAR,
9
+ the CTR stream does not cover it, MAC over sequence + length + ciphertext,
10
+ padding excludes the length field.
11
+ * GCM/ChaCha behave like ETM (length in clear/separate, tag over it + CT).
12
+ * Before NEWKEYS everything is plaintext without MAC.
13
+ """
14
+ from __future__ import annotations
15
+ import asyncio
16
+ import socket
17
+ import struct
18
+ import threading
19
+ import time
20
+ import hmac as pyhmac
21
+ from typing import TYPE_CHECKING
22
+
23
+ from ccssh import chacha as _chacha_mod
24
+ from ccssh import gcm as _gcm_mod
25
+ from ccssh import wire as _wire
26
+ from ccssh.crypto import (
27
+ AesCtrHmacCtx,
28
+ CBC_SPECS,
29
+ CIPHER_SPECS,
30
+ CbcHmacCtx,
31
+ build_aad_block,
32
+ build_unencrypted_block,
33
+ )
34
+
35
+ if TYPE_CHECKING:
36
+ from ccssh.chansock import StreamSocket
37
+
38
+ GCM_CIPHERS = ("aes128-gcm@openssh.com", "aes256-gcm@openssh.com")
39
+ CHACHA_CIPHER = "chacha20-poly1305@openssh.com"
40
+ MAX_PACKET = 35000
41
+
42
+
43
+ class CCSSHError(Exception):
44
+ pass
45
+
46
+
47
+ class CCSSHTimeoutError(CCSSHError):
48
+ """
49
+ A socket read timed out. Subclass of CCSSHError, so existing handlers
50
+ keep working; catch it separately only to tell idleness from failure.
51
+ """
52
+
53
+
54
+ def enc_ignore(data: bytes = b"") -> bytes:
55
+ """
56
+ SSH_MSG_IGNORE (RFC 4253 section 11.1). Every peer MUST ignore it;
57
+ used as a keepalive that also defeats idle NAT timeouts.
58
+ """
59
+ return bytes([2]) + _wire.enc_string(data)
60
+
61
+
62
+ def _global_success(name: bytes) -> bytes:
63
+ """
64
+ GLOBAL_REQUEST reply: hostkeys-00 and keepalive probes succeed (81),
65
+ everything else fails (82). Shared by both transports and the mux.
66
+ """
67
+ ok = name in (b"hostkeys-00@openssh.com", b"keepalive@openssh.com")
68
+ return bytes([81]) if ok else bytes([82])
69
+
70
+
71
+ def _recvn_blocking(sock: socket.socket, n: int, timeout: float, buf: bytearray) -> bytes:
72
+ """
73
+ Read exactly n bytes, keeping partial progress in `buf`.
74
+
75
+ The buffer belongs to the transport and survives timeouts: a slow
76
+ drip-feed must not lose bytes (that would desync the framing), it
77
+ only costs extra keepalive rounds. Exactly n bytes are consumed.
78
+ """
79
+ deadline = time.monotonic() + timeout
80
+ try:
81
+ while len(buf) < n:
82
+ remaining = deadline - time.monotonic()
83
+ if remaining <= 0:
84
+ raise CCSSHTimeoutError("socket read timeout")
85
+ try:
86
+ sock.settimeout(remaining)
87
+ ch = sock.recv(n - len(buf))
88
+ except (TimeoutError, socket.timeout) as e:
89
+ raise CCSSHTimeoutError("socket read timeout") from e
90
+ if not ch:
91
+ raise CCSSHError("connection closed by peer")
92
+ buf += ch
93
+ finally:
94
+ # Always restore the steady-state timeout: a read timeout must not
95
+ # leave a ~0 timeout behind that breaks the next send/recv.
96
+ try:
97
+ sock.settimeout(timeout)
98
+ except Exception:
99
+ pass
100
+ out = bytes(buf[:n])
101
+ del buf[:n]
102
+ return out
103
+
104
+
105
+ async def _recvn_async(reader: asyncio.StreamReader, n: int, timeout: float) -> bytes:
106
+ try:
107
+ if hasattr(asyncio, "timeout"):
108
+ async with asyncio.timeout(timeout):
109
+ return await reader.readexactly(n)
110
+ return await asyncio.wait_for(reader.readexactly(n), timeout)
111
+ except asyncio.IncompleteReadError as e:
112
+ raise CCSSHError("connection closed by peer (async)") from e
113
+ except (asyncio.TimeoutError, TimeoutError) as e:
114
+ raise CCSSHTimeoutError("socket read timeout (async)") from e
115
+
116
+
117
+ def _check_padding(pad: int, pl: int) -> None:
118
+ """
119
+ RFC 4253 section 6: 4 <= pad, and at least 1 payload byte
120
+ (packet_length = 1 + payload + pad, so pad + 2 <= pl).
121
+ """
122
+ if pad < 4 or pad + 2 > pl:
123
+ raise CCSSHError(f"bad padding (pad={pad} pl={pl})")
124
+
125
+
126
+ def _new_ctr_pair(cipher: str, mac: str, keys: dict[str, bytes], *, is_client: bool):
127
+ if is_client:
128
+ s = AesCtrHmacCtx(cipher, mac, keys["enc_c2s"], keys["iv_c2s"], keys["mac_c2s"])
129
+ r = AesCtrHmacCtx(cipher, mac, keys["enc_s2c"], keys["iv_s2c"], keys["mac_s2c"])
130
+ else:
131
+ s = AesCtrHmacCtx(cipher, mac, keys["enc_s2c"], keys["iv_s2c"], keys["mac_s2c"])
132
+ r = AesCtrHmacCtx(cipher, mac, keys["enc_c2s"], keys["iv_c2s"], keys["mac_c2s"])
133
+ return s, r
134
+
135
+
136
+ def _new_cbc_pair(cipher: str, mac: str, keys: dict[str, bytes], *, is_client: bool):
137
+ if is_client:
138
+ s = CbcHmacCtx(cipher, mac, keys["enc_c2s"], keys["iv_c2s"], keys["mac_c2s"])
139
+ r = CbcHmacCtx(cipher, mac, keys["enc_s2c"], keys["iv_s2c"], keys["mac_s2c"])
140
+ else:
141
+ s = CbcHmacCtx(cipher, mac, keys["enc_s2c"], keys["iv_s2c"], keys["mac_s2c"])
142
+ r = CbcHmacCtx(cipher, mac, keys["enc_c2s"], keys["iv_c2s"], keys["mac_c2s"])
143
+ return s, r
144
+
145
+
146
+ def _new_stream_pair(kind: str, cipher: str, mac: str, keys: dict[str, bytes], *, is_client: bool):
147
+ """
148
+ CTR or chained-IV CBC direction pair. Both share one framing and one
149
+ MAC interface, so they ride the same transport slots.
150
+ """
151
+ maker = _new_ctr_pair if kind == "ctr" else _new_cbc_pair
152
+ return maker(cipher, mac, keys, is_client=is_client)
153
+
154
+
155
+ def _new_gcm_pair(cipher: str, keys: dict[str, bytes], *, is_client: bool):
156
+ if is_client:
157
+ s = _gcm_mod.AesGcmCtx(cipher, keys["enc_c2s"], keys["iv_c2s"])
158
+ r = _gcm_mod.AesGcmCtx(cipher, keys["enc_s2c"], keys["iv_s2c"])
159
+ else:
160
+ s = _gcm_mod.AesGcmCtx(cipher, keys["enc_s2c"], keys["iv_s2c"])
161
+ r = _gcm_mod.AesGcmCtx(cipher, keys["enc_c2s"], keys["iv_c2s"])
162
+ return s, r
163
+
164
+
165
+ def _new_chacha_pair(keys: dict[str, bytes], *, is_client: bool):
166
+ if is_client:
167
+ return (_chacha_mod.ChachaSshCtx(keys["enc_c2s"]),
168
+ _chacha_mod.ChachaSshCtx(keys["enc_s2c"]))
169
+ return (_chacha_mod.ChachaSshCtx(keys["enc_s2c"]),
170
+ _chacha_mod.ChachaSshCtx(keys["enc_c2s"]))
171
+
172
+
173
+ def _encode_kind(cipher: str) -> str:
174
+ if cipher in CIPHER_SPECS:
175
+ return "ctr"
176
+ if cipher in CBC_SPECS:
177
+ return "cbc"
178
+ if cipher in GCM_CIPHERS:
179
+ return "gcm"
180
+ if cipher == CHACHA_CIPHER:
181
+ return "chacha"
182
+ raise CCSSHError(f"unsupported cipher {cipher}")
183
+
184
+
185
+ def _encode_encrypted(cipher: str, s, gs, cs, seq: int, payload: bytes) -> bytes:
186
+ """
187
+ Build one encrypted wire packet (no I/O). Shared by the sync and
188
+ async transports so the framing cannot drift between them.
189
+ """
190
+ if cipher in CIPHER_SPECS or cipher in CBC_SPECS:
191
+ if s is None:
192
+ raise CCSSHError("stream sender not activated")
193
+ if s.etm:
194
+ length, body = build_aad_block(payload, s.block_size)
195
+ ct = s.encrypt_stream(body)
196
+ return length + ct + s.mac_of(seq, length + ct)
197
+ blk = build_unencrypted_block(payload, s.block_size)
198
+ return s.encrypt_stream(blk) + s.mac_of(seq, blk)
199
+ if cipher in GCM_CIPHERS:
200
+ if gs is None:
201
+ raise CCSSHError("GCM sender not activated")
202
+ return gs.seal(payload)
203
+ if cipher == CHACHA_CIPHER:
204
+ if cs is None:
205
+ raise CCSSHError("ChaCha sender not activated")
206
+ length, pblk = build_aad_block(payload, 8)
207
+ return cs.seal(seq, length, pblk)
208
+ raise CCSSHError(f"send: unsupported cipher {cipher}")
209
+
210
+
211
+ class SyncTransport:
212
+ def __init__(self, sock: "socket.socket | StreamSocket", version: bytes,
213
+ *, timeout: float = 10.0):
214
+ self.sock = sock
215
+ self.version = version
216
+ self.timeout = timeout
217
+ self.peer_version: bytes | None = None
218
+ self.send_seq = 0
219
+ self.recv_seq = 0
220
+ self.cipher = "none"
221
+ self.mac = "none"
222
+ self._s: AesCtrHmacCtx | CbcHmacCtx | None = None
223
+ self._r: AesCtrHmacCtx | CbcHmacCtx | None = None
224
+ self._gs = self._gr = self._cs = self._cr = None
225
+ self._enc_on_send = False
226
+ self._enc_on_recv = False
227
+ self.bytes_out = 0
228
+ self.bytes_in = 0
229
+ self.kex_at = time.monotonic()
230
+ self._send_lock = threading.Lock()
231
+ self._staged = None
232
+ self._rxbuf = bytearray()
233
+
234
+ def _recvn(self, n: int) -> bytes:
235
+ return _recvn_blocking(self.sock, n, self.timeout, self._rxbuf)
236
+
237
+ def do_version(self) -> bytes:
238
+ self.sock.sendall(self.version + b"\r\n")
239
+ self.sock.settimeout(self.timeout)
240
+ buf = b""
241
+ lines = 0
242
+ t0 = time.monotonic()
243
+ while True:
244
+ if time.monotonic() - t0 > self.timeout:
245
+ raise CCSSHError("version exchange timeout")
246
+ try:
247
+ ch = self.sock.recv(1)
248
+ except (TimeoutError, socket.timeout) as e:
249
+ raise CCSSHTimeoutError("version exchange timeout") from e
250
+ if not ch:
251
+ raise CCSSHError("closed before version string")
252
+ buf += ch
253
+ if len(buf) > 255:
254
+ raise CCSSHError("version line too long")
255
+ if buf.endswith(b"\n"):
256
+ line = buf.strip(b"\r\n")
257
+ if line.startswith(b"SSH-"):
258
+ self.peer_version = line
259
+ return line
260
+ lines += 1
261
+ if lines > 10:
262
+ raise CCSSHError("too many pre-version banners")
263
+ buf = b""
264
+
265
+ def _reset_stats(self) -> None:
266
+ self.kex_at = time.monotonic()
267
+ self.bytes_out = 0
268
+ self.bytes_in = 0
269
+
270
+ def activate(self, keys: dict[str, bytes], cipher: str, mac: str, *, is_client: bool, reset_seq: bool = False) -> None:
271
+ self.cipher, self.mac = cipher, mac
272
+ self._reset_stats()
273
+ if reset_seq:
274
+ self.send_seq = 0
275
+ self.recv_seq = 0
276
+ self._s = self._r = self._gs = self._gr = self._cs = self._cr = None
277
+ self._staged = None
278
+ if cipher == "none":
279
+ return
280
+ kind = _encode_kind(cipher)
281
+ if kind in ("ctr", "cbc"):
282
+ self._s, self._r = _new_stream_pair(kind, cipher, mac, keys, is_client=is_client)
283
+ elif kind == "gcm":
284
+ self._gs, self._gr = _new_gcm_pair(cipher, keys, is_client=is_client)
285
+ else:
286
+ self._cs, self._cr = _new_chacha_pair(keys, is_client=is_client)
287
+
288
+ def stage_rekey(self, keys: dict[str, bytes], cipher: str, mac: str, *, is_client: bool) -> None:
289
+ """Stage new contexts; NEWKEYS itself still travels under old keys."""
290
+ if self._staged is not None:
291
+ raise CCSSHError("rekey already staged")
292
+ kind = _encode_kind(cipher)
293
+ if kind in ("ctr", "cbc"):
294
+ s, r = _new_stream_pair(kind, cipher, mac, keys, is_client=is_client)
295
+ elif kind == "gcm":
296
+ s, r = _new_gcm_pair(cipher, keys, is_client=is_client)
297
+ else:
298
+ s, r = _new_chacha_pair(keys, is_client=is_client)
299
+ self._staged = (kind, cipher, mac, s, r)
300
+
301
+ def commit_rekey_send(self, *, reset_seq: bool = False) -> None:
302
+ if self._staged is None:
303
+ raise CCSSHError("no staged rekey to commit (send)")
304
+ kind, _cipher, _mac, s, _r = self._staged
305
+ if reset_seq:
306
+ self.send_seq = 0
307
+ if kind in ("ctr", "cbc"):
308
+ self._s = s
309
+ elif kind == "gcm":
310
+ self._gs = s
311
+ else:
312
+ self._cs = s
313
+
314
+ def commit_rekey_recv(self, *, reset_seq: bool = False) -> None:
315
+ if self._staged is None:
316
+ raise CCSSHError("no staged rekey to commit (recv)")
317
+ kind, cipher, mac, _s, r = self._staged
318
+ self._reset_stats()
319
+ if reset_seq:
320
+ self.recv_seq = 0
321
+ self.cipher, self.mac = cipher, mac
322
+ if kind in ("ctr", "cbc"):
323
+ self._r = r
324
+ elif kind == "gcm":
325
+ self._gr = r
326
+ else:
327
+ self._cr = r
328
+ self._staged = None
329
+
330
+ def send_packet(self, payload: bytes) -> None:
331
+ """
332
+ One lock covers crypto stream state, seq assignment, and the
333
+ sendall, so threads sharing a transport cannot interleave.
334
+ If sendall fails the stream state is already consumed: treat
335
+ the transport as dead and reconnect instead of retrying.
336
+ """
337
+ with self._send_lock:
338
+ if self.send_seq >= 0xFFFFFFFF:
339
+ raise CCSSHError("sequence number exhausted: rekey required")
340
+ wire = self._encode_packet(payload)
341
+ self.sock.sendall(wire)
342
+ self.bytes_out += len(payload)
343
+ self.send_seq = (self.send_seq + 1) & 0xFFFFFFFF
344
+
345
+ def _encode_packet(self, payload: bytes) -> bytes:
346
+ if self._plain_sending():
347
+ return build_unencrypted_block(payload, 8)
348
+ return _encode_encrypted(self.cipher, self._s, self._gs, self._cs,
349
+ self.send_seq, payload)
350
+
351
+ def _plain_sending(self) -> bool:
352
+ return (self.cipher in ("none",) or not self._enc_on_send or (
353
+ self._s is None and self._gs is None and self._cs is None))
354
+
355
+ def recv_packet(self, *, keepalive: bool = True) -> bytes:
356
+ """
357
+ Next non-service packet. Skips IGNORE/DEBUG and answers
358
+ GLOBAL_REQUEST (hostkeys-00 -> SUCCESS, anything else -> FAILURE).
359
+
360
+ More than 64 service messages in a row means a flooding peer:
361
+ fail instead of spinning. An idle read in steady state (after
362
+ NEWKEYS) sends SSH IGNORE and waits on - an `ssh`-like session
363
+ survives silence instead of dying on the read timeout; pass
364
+ keepalive=False where a stall must stay fatal (e.g. the SFTP
365
+ window top-up, which enforces its own deadline). Handshake reads
366
+ (before NEWKEYS) always fail fast.
367
+ """
368
+ skips = 0
369
+ idle_rounds = 0
370
+ while True:
371
+ try:
372
+ p = self._read_one()
373
+ except CCSSHTimeoutError:
374
+ if not keepalive or not self._enc_on_recv:
375
+ raise
376
+ idle_rounds += 1
377
+ if idle_rounds > 100:
378
+ raise CCSSHError("peer idle: keepalive limit reached")
379
+ self.send_packet(enc_ignore()) # may raise on a dead socket
380
+ continue
381
+ self.bytes_in += len(p)
382
+ m = p[0] if p else -1
383
+ if m in (2, 4): # IGNORE, DEBUG
384
+ skips += 1
385
+ if skips > 64:
386
+ raise CCSSHError("too many service messages")
387
+ continue
388
+ if m == 80: # GLOBAL_REQUEST
389
+ self._answer_global(p)
390
+ skips += 1
391
+ if skips > 64:
392
+ raise CCSSHError("too many service messages")
393
+ continue
394
+ return p
395
+
396
+ def recv_raw(self) -> bytes:
397
+ """
398
+ Exactly one message, no service loop (for select loops: handle it
399
+ and return to select instead of blocking on the next one).
400
+ """
401
+ p = self._read_one()
402
+ self.bytes_in += len(p)
403
+ return p
404
+
405
+ def _answer_global(self, p: bytes) -> None:
406
+ try:
407
+ r = _wire.Reader(p)
408
+ r.byte()
409
+ name, want = r.string(), r.bool()
410
+ except Exception:
411
+ return
412
+ if want:
413
+ self.send_packet(_global_success(name))
414
+
415
+ def _read_one(self) -> bytes:
416
+ if self._plain_receiving():
417
+ hdr = self._recvn(4)
418
+ (pl,) = struct.unpack(">I", hdr)
419
+ if pl < 12 or pl > MAX_PACKET or (4 + pl) % 8 != 0:
420
+ raise CCSSHError(f"packet_length {pl}")
421
+ body = self._recvn(pl)
422
+ _check_padding(body[0], pl)
423
+ self.recv_seq = (self.recv_seq + 1) & 0xFFFFFFFF
424
+ return body[1:len(body) - body[0]]
425
+ if self.cipher in GCM_CIPHERS:
426
+ if self._gr is None:
427
+ raise CCSSHError("GCM receiver not activated")
428
+ hdr = self._recvn(4)
429
+ (pl,) = struct.unpack(">I", hdr)
430
+ if pl < 16 or pl > MAX_PACKET or pl % 16 != 0:
431
+ raise CCSSHError(f"gcm packet_length {pl}")
432
+ rest = self._recvn(pl + 16)
433
+ pt = self._gr.open(hdr + rest)
434
+ _check_padding(pt[0], pl)
435
+ self.recv_seq = (self.recv_seq + 1) & 0xFFFFFFFF
436
+ return pt[1:len(pt) - pt[0]]
437
+ if self.cipher == CHACHA_CIPHER:
438
+ if self._cr is None:
439
+ raise CCSSHError("ChaCha receiver not activated")
440
+ enc_len = self._recvn(4)
441
+ packet_len = self._cr.decrypt_length(self.recv_seq, enc_len)
442
+ (pl,) = struct.unpack(">I", packet_len)
443
+ if pl < 8 or pl > MAX_PACKET or pl % 8 != 0:
444
+ raise CCSSHError(f"chacha packet_length {pl}")
445
+ rest = self._recvn(pl + 16)
446
+ _plen, pblk = self._cr.open(self.recv_seq, enc_len + rest)
447
+ _check_padding(pblk[0], pl)
448
+ self.recv_seq = (self.recv_seq + 1) & 0xFFFFFFFF
449
+ return pblk[1:len(pblk) - pblk[0]]
450
+ ctx = self._r
451
+ if ctx is None:
452
+ raise CCSSHError("stream receiver not activated")
453
+ blk_size = ctx.block_size
454
+ if ctx.etm:
455
+ # Length in the clear; CT of length pl; MAC over seq+length+CT.
456
+ length = self._recvn(4)
457
+ (pl,) = struct.unpack(">I", length)
458
+ if pl < blk_size or pl > MAX_PACKET or pl % blk_size != 0:
459
+ raise CCSSHError(f"ETM packet_length {pl}")
460
+ rest = self._recvn(pl + ctx.mac_len)
461
+ ct, mac_recv = rest[:pl], rest[pl:]
462
+ if not pyhmac.compare_digest(ctx.mac_of(self.recv_seq, length + ct), mac_recv):
463
+ raise CCSSHError("packet integrity failed")
464
+ plain = ctx.decrypt_stream(ct)
465
+ _check_padding(plain[0], pl)
466
+ self.recv_seq = (self.recv_seq + 1) & 0xFFFFFFFF
467
+ return plain[1:len(plain) - plain[0]]
468
+ first = self._recvn(blk_size)
469
+ pf = ctx.decrypt_stream(first)
470
+ (pl,) = struct.unpack(">I", pf[:4])
471
+ if pl < 12 or pl > MAX_PACKET or (4 + pl) % blk_size != 0:
472
+ # Same message as a MAC failure: the length field is decrypted
473
+ # before the MAC is checked, so distinguishing the two would
474
+ # give a padding oracle on legacy CBC.
475
+ raise CCSSHError("packet integrity failed")
476
+ total = 4 + pl + ctx.mac_len
477
+ rest = self._recvn(total - blk_size)
478
+ ct_rest, mac_recv = rest[:len(rest) - ctx.mac_len], rest[len(rest) - ctx.mac_len:]
479
+ plain = pf + ctx.decrypt_stream(ct_rest)
480
+ if not pyhmac.compare_digest(ctx.mac_of(self.recv_seq, plain), mac_recv):
481
+ raise CCSSHError("packet integrity failed")
482
+ if len(plain) != 4 + pl:
483
+ raise CCSSHError("packet integrity failed")
484
+ _check_padding(plain[4], pl)
485
+ self.recv_seq = (self.recv_seq + 1) & 0xFFFFFFFF
486
+ return plain[5:4 + pl - plain[4]]
487
+
488
+ def _plain_receiving(self) -> bool:
489
+ return (self.cipher in ("none",) or (self._r is None and self._gr is None and self._cr is None) or not self._enc_on_recv)
490
+
491
+
492
+ class AsyncTransport:
493
+ """Same protocol over asyncio streams."""
494
+
495
+ def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter,
496
+ version: bytes, *, timeout: float = 10.0):
497
+ self.reader, self.writer = reader, writer
498
+ self.version = version
499
+ self.timeout = timeout
500
+ self.peer_version: bytes | None = None
501
+ self.send_seq = 0
502
+ self.recv_seq = 0
503
+ self.cipher = "none"
504
+ self.mac = "none"
505
+ self._s: AesCtrHmacCtx | CbcHmacCtx | None = None
506
+ self._r: AesCtrHmacCtx | CbcHmacCtx | None = None
507
+ self._gs = self._gr = self._cs = self._cr = None
508
+ self._enc_on_send = False
509
+ self._enc_on_recv = False
510
+ self.bytes_out = 0
511
+ self.bytes_in = 0
512
+ self.kex_at = time.monotonic()
513
+ self._staged = None
514
+ self._send_lock = asyncio.Lock()
515
+
516
+ async def do_version(self) -> bytes:
517
+ self.writer.write(self.version + b"\r\n")
518
+ await self.writer.drain()
519
+ buf = b""
520
+ lines = 0
521
+ t0 = time.monotonic()
522
+ while True:
523
+ if time.monotonic() - t0 > self.timeout:
524
+ raise CCSSHError("version exchange timeout (async)")
525
+ ch = await _recvn_async(self.reader, 1, self.timeout)
526
+ buf += ch
527
+ if len(buf) > 255:
528
+ raise CCSSHError("version line too long (async)")
529
+ if buf.endswith(b"\n"):
530
+ line = buf.strip(b"\r\n")
531
+ if line.startswith(b"SSH-"):
532
+ self.peer_version = line
533
+ return line
534
+ lines += 1
535
+ if lines > 10:
536
+ raise CCSSHError("too many pre-version banners (async)")
537
+ buf = b""
538
+
539
+ def _reset_stats(self) -> None:
540
+ self.kex_at = time.monotonic()
541
+ self.bytes_out = 0
542
+ self.bytes_in = 0
543
+
544
+ def activate(self, keys: dict[str, bytes], cipher: str, mac: str, *, is_client: bool, reset_seq: bool = False) -> None:
545
+ self.cipher, self.mac = cipher, mac
546
+ self._reset_stats()
547
+ if reset_seq:
548
+ self.send_seq = 0
549
+ self.recv_seq = 0
550
+ self._s = self._r = self._gs = self._gr = self._cs = self._cr = None
551
+ self._staged = None
552
+ if cipher == "none":
553
+ return
554
+ kind = _encode_kind(cipher)
555
+ if kind in ("ctr", "cbc"):
556
+ self._s, self._r = _new_stream_pair(kind, cipher, mac, keys, is_client=is_client)
557
+ elif kind == "gcm":
558
+ self._gs, self._gr = _new_gcm_pair(cipher, keys, is_client=is_client)
559
+ else:
560
+ self._cs, self._cr = _new_chacha_pair(keys, is_client=is_client)
561
+
562
+ def stage_rekey(self, keys: dict[str, bytes], cipher: str, mac: str, *, is_client: bool) -> None:
563
+ if self._staged is not None:
564
+ raise CCSSHError("rekey already staged")
565
+ kind = _encode_kind(cipher)
566
+ if kind in ("ctr", "cbc"):
567
+ s, r = _new_stream_pair(kind, cipher, mac, keys, is_client=is_client)
568
+ elif kind == "gcm":
569
+ s, r = _new_gcm_pair(cipher, keys, is_client=is_client)
570
+ else:
571
+ s, r = _new_chacha_pair(keys, is_client=is_client)
572
+ self._staged = (kind, cipher, mac, s, r)
573
+
574
+ def commit_rekey_send(self, *, reset_seq: bool = False) -> None:
575
+ if self._staged is None:
576
+ raise CCSSHError("no staged rekey to commit (send)")
577
+ kind, _cipher, _mac, s, _r = self._staged
578
+ if reset_seq:
579
+ self.send_seq = 0
580
+ if kind in ("ctr", "cbc"):
581
+ self._s = s
582
+ elif kind == "gcm":
583
+ self._gs = s
584
+ else:
585
+ self._cs = s
586
+
587
+ def commit_rekey_recv(self, *, reset_seq: bool = False) -> None:
588
+ if self._staged is None:
589
+ raise CCSSHError("no staged rekey to commit (recv)")
590
+ kind, cipher, mac, _s, r = self._staged
591
+ self._reset_stats()
592
+ if reset_seq:
593
+ self.recv_seq = 0
594
+ self.cipher, self.mac = cipher, mac
595
+ if kind in ("ctr", "cbc"):
596
+ self._r = r
597
+ elif kind == "gcm":
598
+ self._gr = r
599
+ else:
600
+ self._cr = r
601
+ self._staged = None
602
+
603
+ async def send_packet(self, payload: bytes) -> None:
604
+ async with self._send_lock:
605
+ if self.send_seq >= 0xFFFFFFFF:
606
+ raise CCSSHError("sequence number exhausted: rekey required")
607
+ if self._plain_sending():
608
+ wire = build_unencrypted_block(payload, 8)
609
+ else:
610
+ wire = _encode_encrypted(self.cipher, self._s, self._gs, self._cs, self.send_seq, payload)
611
+ self.writer.write(wire)
612
+ await self.writer.drain()
613
+ self.bytes_out += len(payload)
614
+ self.send_seq = (self.send_seq + 1) & 0xFFFFFFFF
615
+
616
+ def _plain_sending(self) -> bool:
617
+ return (self.cipher in ("none",) or not self._enc_on_send or (
618
+ self._s is None and self._gs is None and self._cs is None))
619
+
620
+ async def recv_packet(self, *, keepalive: bool = True) -> bytes:
621
+ skips = 0
622
+ idle_rounds = 0
623
+ while True:
624
+ try:
625
+ p = await self._read_one()
626
+ except CCSSHTimeoutError:
627
+ if not keepalive or not self._enc_on_recv:
628
+ raise
629
+ idle_rounds += 1
630
+ if idle_rounds > 100:
631
+ raise CCSSHError("peer idle: keepalive limit reached (async)")
632
+ await self.send_packet(enc_ignore()) # may raise on a dead socket
633
+ continue
634
+ self.bytes_in += len(p)
635
+ m = p[0] if p else -1
636
+ if m in (2, 4):
637
+ skips += 1
638
+ if skips > 64:
639
+ raise CCSSHError("too many service messages (async)")
640
+ continue
641
+ if m == 80:
642
+ await self._answer_global(p)
643
+ skips += 1
644
+ if skips > 64:
645
+ raise CCSSHError("too many service messages (async)")
646
+ continue
647
+ return p
648
+
649
+ async def recv_raw(self) -> bytes:
650
+ """Single message without the service loop, for async select loops."""
651
+ p = await self._read_one()
652
+ self.bytes_in += len(p)
653
+ return p
654
+
655
+ async def _answer_global(self, p: bytes) -> None:
656
+ try:
657
+ r = _wire.Reader(p)
658
+ r.byte()
659
+ name, want = r.string(), r.bool()
660
+ except Exception:
661
+ return
662
+ if want:
663
+ await self.send_packet(_global_success(name))
664
+
665
+ async def _read_one(self) -> bytes:
666
+ if self._plain_receiving():
667
+ hdr = await _recvn_async(self.reader, 4, self.timeout)
668
+ (pl,) = struct.unpack(">I", hdr)
669
+ if pl < 12 or pl > MAX_PACKET or (4 + pl) % 8 != 0:
670
+ raise CCSSHError(f"packet_length {pl}")
671
+ body = await _recvn_async(self.reader, pl, self.timeout)
672
+ _check_padding(body[0], pl)
673
+ self.recv_seq = (self.recv_seq + 1) & 0xFFFFFFFF
674
+ return body[1:len(body) - body[0]]
675
+ if self.cipher in GCM_CIPHERS:
676
+ if self._gr is None:
677
+ raise CCSSHError("GCM receiver not activated")
678
+ hdr = await _recvn_async(self.reader, 4, self.timeout)
679
+ (pl,) = struct.unpack(">I", hdr)
680
+ if pl < 16 or pl > MAX_PACKET or pl % 16 != 0:
681
+ raise CCSSHError(f"gcm packet_length {pl}")
682
+ rest = await _recvn_async(self.reader, pl + 16, self.timeout)
683
+ pt = self._gr.open(hdr + rest)
684
+ _check_padding(pt[0], pl)
685
+ self.recv_seq = (self.recv_seq + 1) & 0xFFFFFFFF
686
+ return pt[1:len(pt) - pt[0]]
687
+ if self.cipher == CHACHA_CIPHER:
688
+ if self._cr is None:
689
+ raise CCSSHError("ChaCha receiver not activated")
690
+ enc_len = await _recvn_async(self.reader, 4, self.timeout)
691
+ (pl,) = struct.unpack(">I", self._cr.decrypt_length(self.recv_seq, enc_len))
692
+ if pl < 8 or pl > MAX_PACKET or pl % 8 != 0:
693
+ raise CCSSHError(f"chacha packet_length {pl}")
694
+ rest = await _recvn_async(self.reader, pl + 16, self.timeout)
695
+ _plen, pblk = self._cr.open(self.recv_seq, enc_len + rest)
696
+ _check_padding(pblk[0], pl)
697
+ self.recv_seq = (self.recv_seq + 1) & 0xFFFFFFFF
698
+ return pblk[1:len(pblk) - pblk[0]]
699
+ ctx = self._r
700
+ if ctx is None:
701
+ raise CCSSHError("stream receiver not activated (async)")
702
+ blk_size = ctx.block_size
703
+ if ctx.etm:
704
+ length = await _recvn_async(self.reader, 4, self.timeout)
705
+ (pl,) = struct.unpack(">I", length)
706
+ if pl < blk_size or pl > MAX_PACKET or pl % blk_size != 0:
707
+ raise CCSSHError(f"ETM packet_length {pl}")
708
+ rest = await _recvn_async(self.reader, pl + ctx.mac_len, self.timeout)
709
+ ct, mac_recv = rest[:pl], rest[pl:]
710
+ if not pyhmac.compare_digest(ctx.mac_of(self.recv_seq, length + ct), mac_recv):
711
+ raise CCSSHError("packet integrity failed (async)")
712
+ plain = ctx.decrypt_stream(ct)
713
+ _check_padding(plain[0], pl)
714
+ self.recv_seq = (self.recv_seq + 1) & 0xFFFFFFFF
715
+ return plain[1:len(plain) - plain[0]]
716
+ first = await _recvn_async(self.reader, blk_size, self.timeout)
717
+ pf = ctx.decrypt_stream(first)
718
+ (pl,) = struct.unpack(">I", pf[:4])
719
+ if pl < 12 or pl > MAX_PACKET or (4 + pl) % blk_size != 0:
720
+ # Same message as a MAC failure: the length field is decrypted
721
+ # before the MAC is checked, so distinguishing the two would
722
+ # give a padding oracle on legacy CBC.
723
+ raise CCSSHError("packet integrity failed (async)")
724
+ total = 4 + pl + ctx.mac_len
725
+ rest = await _recvn_async(self.reader, total - blk_size, self.timeout)
726
+ ct_rest, mac_recv = rest[:len(rest) - ctx.mac_len], rest[len(rest) - ctx.mac_len:]
727
+ plain = pf + ctx.decrypt_stream(ct_rest)
728
+ if not pyhmac.compare_digest(ctx.mac_of(self.recv_seq, plain), mac_recv):
729
+ raise CCSSHError("packet integrity failed (async)")
730
+ if len(plain) != 4 + pl:
731
+ raise CCSSHError("packet integrity failed (async)")
732
+ _check_padding(plain[4], pl)
733
+ self.recv_seq = (self.recv_seq + 1) & 0xFFFFFFFF
734
+ return plain[5:4 + pl - plain[4]]
735
+
736
+ def _plain_receiving(self) -> bool:
737
+ return (self.cipher in ("none",) or (self._r is None and self._gr is None and self._cr is None) or not self._enc_on_recv)