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