codecraftai-protocol-ssh 1.3.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.
- ccai/protocols/ssh/__init__.py +3 -0
- ccai/protocols/ssh/py.typed +0 -0
- ccai/protocols/ssh/sshagent.py +375 -0
- ccai/protocols/ssh/sshauth.py +118 -0
- ccai/protocols/ssh/sshbase.py +780 -0
- ccai/protocols/ssh/sshclient.py +170 -0
- ccai/protocols/ssh/sshconnection.py +1285 -0
- ccai/protocols/ssh/sshconst.py +49 -0
- ccai/protocols/ssh/sshconstants.py +185 -0
- ccai/protocols/ssh/sshhelpers.py +695 -0
- ccai/protocols/ssh/sshkeys.py +197 -0
- ccai/protocols/ssh/sshpacket.py +138 -0
- ccai/protocols/ssh/sshsession.py +433 -0
- ccai/protocols/ssh/sshsftp.py +297 -0
- ccai/protocols/ssh/sshtransport.py +756 -0
- ccai/protocols/ssh/sshtunnel.py +464 -0
- codecraftai_protocol_ssh-1.3.0.dist-info/METADATA +98 -0
- codecraftai_protocol_ssh-1.3.0.dist-info/RECORD +20 -0
- codecraftai_protocol_ssh-1.3.0.dist-info/WHEEL +4 -0
- codecraftai_protocol_ssh-1.3.0.dist-info/licenses/LICENSE.md +204 -0
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
import socket
|
|
2
|
+
import threading
|
|
3
|
+
import logging
|
|
4
|
+
import struct
|
|
5
|
+
import os
|
|
6
|
+
import hashlib
|
|
7
|
+
from cryptography.hazmat.primitives import hashes, hmac
|
|
8
|
+
from cryptography.hazmat.primitives.asymmetric import dh, rsa, ed25519
|
|
9
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
10
|
+
from cryptography.hazmat.backends import default_backend
|
|
11
|
+
|
|
12
|
+
from ccai.protocols.ssh import sshconstants
|
|
13
|
+
from ccai.protocols.ssh.sshpacket import SshPacket
|
|
14
|
+
from ccai.protocols.ssh.sshkeys import SshRSAKey, SshEd25519Key
|
|
15
|
+
|
|
16
|
+
# Setup logger
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
class SshTransport(threading.Thread):
|
|
20
|
+
"""
|
|
21
|
+
SSH Transport Layer implementation.
|
|
22
|
+
Handles encryption, decryption, compression, and key exchange.
|
|
23
|
+
Run as a thread to handle incoming data.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, sock: socket.socket):
|
|
27
|
+
super().__init__()
|
|
28
|
+
self.sock = sock
|
|
29
|
+
self.active = False
|
|
30
|
+
self.stop_event = threading.Event()
|
|
31
|
+
self.lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
# Versions
|
|
34
|
+
self.remote_version = None
|
|
35
|
+
self.local_version = sshconstants.MSG_STR_VERSION
|
|
36
|
+
|
|
37
|
+
# State
|
|
38
|
+
self.session_id = None
|
|
39
|
+
self.packet_queue = [] # Replace with Queue in prod
|
|
40
|
+
|
|
41
|
+
# Cipher state (Initially None/Cleartext)
|
|
42
|
+
self.encryptor = None
|
|
43
|
+
self.decryptor = None
|
|
44
|
+
self.hmac_out = None # Deprecated/unused field? Removed in previous step but let's keep clean.
|
|
45
|
+
self.hmac_in = None
|
|
46
|
+
self.hmac_out_key = None
|
|
47
|
+
self.hmac_in_key = None
|
|
48
|
+
self.mac_len_out = 0
|
|
49
|
+
self.mac_len_in = 0
|
|
50
|
+
|
|
51
|
+
# Sequence Numbers
|
|
52
|
+
self.seqno_out = 0
|
|
53
|
+
self.seqno_in = 0
|
|
54
|
+
|
|
55
|
+
# KEX State
|
|
56
|
+
self.kex_init_sent = False
|
|
57
|
+
self.kex_init_rcvd = False
|
|
58
|
+
|
|
59
|
+
# Diffie-Hellman State
|
|
60
|
+
self.dh_private_key = None
|
|
61
|
+
self.dh_public_key = None
|
|
62
|
+
self.shared_secret_K = None
|
|
63
|
+
self.exchange_hash_H = None
|
|
64
|
+
self.host_key = None # The server's host key
|
|
65
|
+
|
|
66
|
+
# Exchange values
|
|
67
|
+
self.client_kex_init_payload = None
|
|
68
|
+
self.server_kex_init_payload = None
|
|
69
|
+
|
|
70
|
+
# Buffer for incoming data
|
|
71
|
+
self._buffer = bytearray()
|
|
72
|
+
|
|
73
|
+
# Set once both traffic directions are encrypted (server NEWKEYS
|
|
74
|
+
# received), or when key exchange fails. Callers wait on this before
|
|
75
|
+
# starting authentication and then inspect kex_error.
|
|
76
|
+
self.kex_complete = threading.Event()
|
|
77
|
+
# Records the exception that aborted key exchange (e.g. an untrusted
|
|
78
|
+
# host key) so the connecting caller can surface the real reason instead
|
|
79
|
+
# of a generic timeout.
|
|
80
|
+
self.kex_error = None
|
|
81
|
+
|
|
82
|
+
# Handler for non-transport messages (auth, then connection). When set,
|
|
83
|
+
# received application packets are delivered to it directly instead of
|
|
84
|
+
# being queued.
|
|
85
|
+
self._message_handler = None
|
|
86
|
+
|
|
87
|
+
# Host-key trust. hostname is the connection target; host_key_verifier is
|
|
88
|
+
# a callable (hostname, key_type, key_blob) -> bool. If it returns False
|
|
89
|
+
# or is not configured, the server's host key is rejected (no blind trust).
|
|
90
|
+
self.hostname = None
|
|
91
|
+
self.host_key_verifier = None
|
|
92
|
+
|
|
93
|
+
def connect(self):
|
|
94
|
+
"""
|
|
95
|
+
Perform initial SSH handshake.
|
|
96
|
+
"""
|
|
97
|
+
self.active = True
|
|
98
|
+
|
|
99
|
+
# 1. Send Version String
|
|
100
|
+
self._send_version()
|
|
101
|
+
|
|
102
|
+
# 2. Receive Version String
|
|
103
|
+
self._receive_version()
|
|
104
|
+
|
|
105
|
+
# 3. Start processing thread
|
|
106
|
+
self.start()
|
|
107
|
+
|
|
108
|
+
# 4. Start Key Exchange (KEXINIT)
|
|
109
|
+
self.send_kex_init()
|
|
110
|
+
|
|
111
|
+
def run(self):
|
|
112
|
+
"""
|
|
113
|
+
Main loop for reading packets from the socket.
|
|
114
|
+
"""
|
|
115
|
+
logger.info("Transport thread started")
|
|
116
|
+
while self.active and not self.stop_event.is_set():
|
|
117
|
+
try:
|
|
118
|
+
packet = self._read_packet()
|
|
119
|
+
if packet:
|
|
120
|
+
self._process_packet(packet)
|
|
121
|
+
except Exception as e:
|
|
122
|
+
# A read error during intentional shutdown (socket closed by
|
|
123
|
+
# close()) is expected; exit quietly.
|
|
124
|
+
if self.stop_event.is_set():
|
|
125
|
+
break
|
|
126
|
+
logger.error(f"Error in transport loop: {e}")
|
|
127
|
+
# If the failure happened before key exchange finished, record it
|
|
128
|
+
# and release any caller waiting on kex_complete so it can raise
|
|
129
|
+
# the real reason (e.g. an untrusted host key) rather than a timeout.
|
|
130
|
+
if not self.kex_complete.is_set():
|
|
131
|
+
self.kex_error = e
|
|
132
|
+
self.kex_complete.set()
|
|
133
|
+
self.close()
|
|
134
|
+
break
|
|
135
|
+
logger.info("Transport thread stopped")
|
|
136
|
+
|
|
137
|
+
def is_active(self) -> bool:
|
|
138
|
+
"""
|
|
139
|
+
Returns True while the transport is connected and its reader thread is processing packets. Mirrors
|
|
140
|
+
``paramiko.Transport.is_active``, which :class:`SshTunnel` uses to stop forwarding on a dead transport.
|
|
141
|
+
|
|
142
|
+
:returns: True if the transport is active, otherwise False.
|
|
143
|
+
"""
|
|
144
|
+
active = self.active
|
|
145
|
+
return active
|
|
146
|
+
|
|
147
|
+
def close(self):
|
|
148
|
+
self.active = False
|
|
149
|
+
self.stop_event.set()
|
|
150
|
+
try:
|
|
151
|
+
self.sock.close()
|
|
152
|
+
except Exception:
|
|
153
|
+
# Best-effort close; the socket may already be closed or broken because close() is also called
|
|
154
|
+
# from the error path of the transport loop.
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
def _send_version(self):
|
|
158
|
+
v_str = f"{self.local_version}\r\n".encode('utf-8')
|
|
159
|
+
self.sock.sendall(v_str)
|
|
160
|
+
|
|
161
|
+
def _receive_version(self):
|
|
162
|
+
# Spec says we can receive lines before the version string.
|
|
163
|
+
# We must look for "SSH-"
|
|
164
|
+
buf = b""
|
|
165
|
+
while True:
|
|
166
|
+
recv = self.sock.recv(1)
|
|
167
|
+
if not recv:
|
|
168
|
+
raise ConnectionError("Connection closed while waiting for version")
|
|
169
|
+
buf += recv
|
|
170
|
+
if buf.endswith(b"\n"):
|
|
171
|
+
line = buf.strip()
|
|
172
|
+
if line.startswith(b"SSH-"):
|
|
173
|
+
self.remote_version = line.decode('utf-8')
|
|
174
|
+
break
|
|
175
|
+
buf = b"" # Discard non-version lines
|
|
176
|
+
|
|
177
|
+
def send_packet(self, packet: SshPacket):
|
|
178
|
+
"""
|
|
179
|
+
Encapsulate and send a packet.
|
|
180
|
+
Format:
|
|
181
|
+
uint32 packet_length
|
|
182
|
+
byte padding_length
|
|
183
|
+
byte[n1] payload; n1 = packet_length - padding_length - 1
|
|
184
|
+
byte[n2] random padding; n2 = padding_length
|
|
185
|
+
byte[m] mac (Message Authentication Code - MAC); m = mac_length
|
|
186
|
+
"""
|
|
187
|
+
payload = packet.to_bytes()
|
|
188
|
+
|
|
189
|
+
# Calculate padding
|
|
190
|
+
# Block size (encryptor block size or 8)
|
|
191
|
+
block_size = 8
|
|
192
|
+
if self.encryptor:
|
|
193
|
+
# AES block size is 128 bit = 16 bytes.
|
|
194
|
+
# But CTR mode is stream-like.
|
|
195
|
+
# SSH protocol: "The length of the packet payload... length field... etc."
|
|
196
|
+
# "The packet length ... must be a multiple of the cipher block size or 8, whichever is larger."
|
|
197
|
+
# For AES (block cipher), block size is 16.
|
|
198
|
+
# Even in CTR mode, SSH framing might respect block size for padding alignment?
|
|
199
|
+
# RFC 4344: "The 'block size' for ... aes128-ctr ... is 16 octets."
|
|
200
|
+
block_size = 16
|
|
201
|
+
|
|
202
|
+
# len(packet_length) + len(padding_length) + len(payload) + len(padding)
|
|
203
|
+
# 4 + 1 + len(payload) + pad_len must be multiple of block_size
|
|
204
|
+
# Minimum padding is 4 bytes.
|
|
205
|
+
|
|
206
|
+
current_len = 4 + 1 + len(payload)
|
|
207
|
+
pad_len = block_size - (current_len % block_size)
|
|
208
|
+
if pad_len < 4:
|
|
209
|
+
pad_len += block_size
|
|
210
|
+
|
|
211
|
+
padding = os.urandom(pad_len)
|
|
212
|
+
|
|
213
|
+
packet_len = 1 + len(payload) + len(padding) # Excluding 'packet_length' field itself (4 bytes)
|
|
214
|
+
|
|
215
|
+
out = struct.pack('>I', packet_len)
|
|
216
|
+
out += struct.pack('B', pad_len)
|
|
217
|
+
out += payload
|
|
218
|
+
out += padding
|
|
219
|
+
|
|
220
|
+
# Encrypt 'out' (excluding packet_len field if not etm? SSH default is encrypt payload+padding+len?)
|
|
221
|
+
# RFC 4253:
|
|
222
|
+
# The ciphertext is produced by correctly encrypting the plaintext packet.
|
|
223
|
+
# Plaintext packet: packet_length || padding_length || payload || padding
|
|
224
|
+
# So we encrypt the WHOLE thing except the mac.
|
|
225
|
+
# Wait, some ciphers (stream) encrypt length too?
|
|
226
|
+
# RFC 4253 Section 6:
|
|
227
|
+
# "The packet length, padding length, payload, and padding are encrypted."
|
|
228
|
+
|
|
229
|
+
# MAC is computed over seqno || unencrypted_packet; the ciphertext is a
|
|
230
|
+
# single encryption of that same unencrypted packet. Hold the lock across
|
|
231
|
+
# MAC + encrypt + send + sequence increment so concurrent senders cannot
|
|
232
|
+
# desync the CTR keystream or the sequence number.
|
|
233
|
+
with self.lock:
|
|
234
|
+
mac = b''
|
|
235
|
+
if self.hmac_out_key is not None:
|
|
236
|
+
seqno_bytes = struct.pack('>I', self.seqno_out)
|
|
237
|
+
h = hmac.HMAC(self.hmac_out_key, hashes.SHA1(), backend=default_backend())
|
|
238
|
+
h.update(seqno_bytes)
|
|
239
|
+
h.update(out)
|
|
240
|
+
mac = h.finalize()
|
|
241
|
+
|
|
242
|
+
final_data = out
|
|
243
|
+
if self.encryptor is not None:
|
|
244
|
+
final_data = self.encryptor.update(out)
|
|
245
|
+
|
|
246
|
+
self.sock.sendall(final_data + mac)
|
|
247
|
+
|
|
248
|
+
# The packet sequence number increments for every packet sent,
|
|
249
|
+
# encrypted or not, and is never reset.
|
|
250
|
+
self.seqno_out = (self.seqno_out + 1) & 0xffffffff
|
|
251
|
+
|
|
252
|
+
def send_kex_init(self):
|
|
253
|
+
pkt = SshPacket(sshconstants.SSH_MSG_KEXINIT)
|
|
254
|
+
cookie = os.urandom(16)
|
|
255
|
+
pkt.add_bytes(cookie) # 16-byte cookie: raw bytes, NOT a length-prefixed string
|
|
256
|
+
pkt.add_name_list(["diffie-hellman-group14-sha1"]) # kex_algorithms
|
|
257
|
+
pkt.add_name_list(["ssh-rsa", "ssh-ed25519"]) # server_host_key_algorithms
|
|
258
|
+
pkt.add_name_list(["aes128-ctr"]) # encryption_algorithms_client_to_server
|
|
259
|
+
pkt.add_name_list(["aes128-ctr"]) # encryption_algorithms_server_to_client
|
|
260
|
+
pkt.add_name_list(["hmac-sha1"]) # mac_algorithms_client_to_server
|
|
261
|
+
pkt.add_name_list(["hmac-sha1"]) # mac_algorithms_server_to_client
|
|
262
|
+
pkt.add_name_list(["none"]) # compression_algorithms_client_to_server
|
|
263
|
+
pkt.add_name_list(["none"]) # compression_algorithms_server_to_client
|
|
264
|
+
pkt.add_name_list([""]) # languages_client_to_server
|
|
265
|
+
pkt.add_name_list([""]) # languages_server_to_client
|
|
266
|
+
pkt.add_boolean(False) # kex_first_packet_follows
|
|
267
|
+
pkt.add_uint32(0) # reserved
|
|
268
|
+
|
|
269
|
+
self.client_kex_init_payload = pkt.payload[1:] # Store payload without msg_id for H calc (msg_id is byte 0)
|
|
270
|
+
# Wait, per RFC 4253, I_C is the payload of the client's KEXINIT packet.
|
|
271
|
+
# "The payload of the client's KEXINIT packet."
|
|
272
|
+
# Does it include the message ID?
|
|
273
|
+
# RFC 4253 Section 7. Key Exchange:
|
|
274
|
+
# H = hash(V_C || V_S || I_C || I_S || K_S || e || f || K)
|
|
275
|
+
# I_C: "The payload of the client's SSH_MSG_KEXINIT packet."
|
|
276
|
+
# Usually this means everything starting from SSH_MSG_KEXINIT byte.
|
|
277
|
+
# My Packet.payload INCLUDES the message ID if I initialized it with one?
|
|
278
|
+
# Packet(constants.SSH_MSG_KEXINIT) sets self.message_id = 20.
|
|
279
|
+
# But payload is empty initially.
|
|
280
|
+
# When I call to_bytes(), it prepends message_id.
|
|
281
|
+
# So I should store the FULL packet bytes for hash calculation.
|
|
282
|
+
|
|
283
|
+
self.kex_init_sent = True
|
|
284
|
+
self.send_packet(pkt)
|
|
285
|
+
self.client_kex_init_payload = pkt.to_bytes()
|
|
286
|
+
|
|
287
|
+
def _read_packet(self):
|
|
288
|
+
"""
|
|
289
|
+
Read a single SSH packet from the socket.
|
|
290
|
+
"""
|
|
291
|
+
# 1. Read first block (or 4 bytes for length if no encryption)
|
|
292
|
+
# Block size is 8 for unencrypted
|
|
293
|
+
# 1. Read first block (or 4 bytes for length if no encryption)
|
|
294
|
+
# Block size is 8 for unencrypted
|
|
295
|
+
block_size = 8
|
|
296
|
+
if self.decryptor:
|
|
297
|
+
block_size = 16
|
|
298
|
+
|
|
299
|
+
try:
|
|
300
|
+
# We need to read enough to get packet length.
|
|
301
|
+
# packet_len is 4 bytes.
|
|
302
|
+
# If encrypted, we must read a full block to decrypt the first block which contains length.
|
|
303
|
+
|
|
304
|
+
first_block = self._read_bytes(block_size)
|
|
305
|
+
if not first_block:
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
if self.decryptor:
|
|
309
|
+
first_block = self.decryptor.update(first_block)
|
|
310
|
+
|
|
311
|
+
# Extract packet length (uint32)
|
|
312
|
+
packet_len = struct.unpack('>I', first_block[:4])[0]
|
|
313
|
+
|
|
314
|
+
# Calculate remaining bytes to read
|
|
315
|
+
# We already read 'block_size' bytes.
|
|
316
|
+
# Total encrypted part is 'packet_len' + 4 (size field itself is not included in packet_len usually? Wait)
|
|
317
|
+
# RFC 4253: "packet_length: The length of the packet in bytes, not including 'mac' or 'packet_length' field itself."
|
|
318
|
+
# So total data AFTER the 4-byte length field is 'packet_len'.
|
|
319
|
+
# We read 'block_size' bytes. 4 bytes were length. So we read 'block_size - 4' bytes of the rest.
|
|
320
|
+
|
|
321
|
+
bytes_read_so_far = block_size - 4
|
|
322
|
+
bytes_remaining = packet_len - bytes_read_so_far
|
|
323
|
+
|
|
324
|
+
if bytes_remaining < 0:
|
|
325
|
+
raise ValueError("Invalid packet length")
|
|
326
|
+
|
|
327
|
+
data = self._read_bytes(bytes_remaining)
|
|
328
|
+
if len(data) < bytes_remaining:
|
|
329
|
+
raise ConnectionError("Connection closed during packet read")
|
|
330
|
+
|
|
331
|
+
if self.decryptor:
|
|
332
|
+
data = self.decryptor.update(data)
|
|
333
|
+
|
|
334
|
+
# Full decrypted payload (including padding_len and padding) is:
|
|
335
|
+
# first_block[4:] + data
|
|
336
|
+
|
|
337
|
+
full_data = first_block[4:] + data
|
|
338
|
+
|
|
339
|
+
# Padding length is first byte
|
|
340
|
+
pad_len = full_data[0]
|
|
341
|
+
|
|
342
|
+
# Payload is restart
|
|
343
|
+
# payload = full_data[1 : -pad_len]
|
|
344
|
+
# Check bounds
|
|
345
|
+
if pad_len > len(full_data) - 1:
|
|
346
|
+
raise ValueError("Invalid padding length")
|
|
347
|
+
|
|
348
|
+
payload = full_data[1 : len(full_data) - pad_len]
|
|
349
|
+
|
|
350
|
+
# TODO: Check MAC (read mac_len bytes)
|
|
351
|
+
if self.hmac_in_key and self.mac_len_in > 0:
|
|
352
|
+
mac = self._read_bytes(self.mac_len_in)
|
|
353
|
+
if not mac or len(mac) < self.mac_len_in:
|
|
354
|
+
raise ConnectionError("Connection closed during MAC read")
|
|
355
|
+
|
|
356
|
+
# Verify MAC
|
|
357
|
+
# mac = HMAC(key, seqno || unencrypted_packet)
|
|
358
|
+
# unencrypted_packet is what we decrypted (full_data) PLUS the length field (which was in first_block[:4])
|
|
359
|
+
# Wait, 'full_data' is first_block[4:] + data.
|
|
360
|
+
# So we need to reconstruct the "unencrypted packet" as it was sent.
|
|
361
|
+
# It includes packet_length field.
|
|
362
|
+
|
|
363
|
+
packet_len_bytes = struct.pack('>I', packet_len)
|
|
364
|
+
unencrypted_packet = packet_len_bytes + full_data
|
|
365
|
+
|
|
366
|
+
seqno_bytes = struct.pack('>I', self.seqno_in)
|
|
367
|
+
|
|
368
|
+
h = hmac.HMAC(self.hmac_in_key, hashes.SHA1(), backend=default_backend())
|
|
369
|
+
h.update(seqno_bytes)
|
|
370
|
+
h.update(unencrypted_packet)
|
|
371
|
+
expected_mac = h.finalize()
|
|
372
|
+
|
|
373
|
+
if mac != expected_mac:
|
|
374
|
+
raise Exception("MAC Verification Failed!")
|
|
375
|
+
|
|
376
|
+
# The inbound sequence number increments for every packet received,
|
|
377
|
+
# encrypted or not, and is never reset.
|
|
378
|
+
self.seqno_in = (self.seqno_in + 1) & 0xffffffff
|
|
379
|
+
|
|
380
|
+
return SshPacket(payload=payload)
|
|
381
|
+
|
|
382
|
+
except (ConnectionError, struct.error) as e:
|
|
383
|
+
logger.warning(f"Read error: {e}")
|
|
384
|
+
raise e
|
|
385
|
+
|
|
386
|
+
def _read_bytes(self, n):
|
|
387
|
+
"""Helper to read exactly n bytes"""
|
|
388
|
+
data = b''
|
|
389
|
+
while len(data) < n:
|
|
390
|
+
chunk = self.sock.recv(n - len(data))
|
|
391
|
+
if not chunk:
|
|
392
|
+
return data if len(data) > 0 else None # Return partial or None? if 0 return None
|
|
393
|
+
data += chunk
|
|
394
|
+
return data
|
|
395
|
+
|
|
396
|
+
def _process_packet(self, packet: SshPacket):
|
|
397
|
+
# Extract message ID
|
|
398
|
+
if not packet.payload:
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
msg_id = packet.payload[0]
|
|
402
|
+
# Rewrap packet with correct ID
|
|
403
|
+
# Wait, my Packet class assumes payload includes msg_id if unpacked?
|
|
404
|
+
# Packet(payload=...) puts it in payload.
|
|
405
|
+
# packet.get_byte() gets the first byte.
|
|
406
|
+
|
|
407
|
+
# Let's peek
|
|
408
|
+
# msg_id is already in payload[0]
|
|
409
|
+
|
|
410
|
+
logger.debug(f"Received packet {msg_id}")
|
|
411
|
+
|
|
412
|
+
if msg_id == sshconstants.SSH_MSG_KEXINIT:
|
|
413
|
+
self._handle_kex_init(packet)
|
|
414
|
+
elif msg_id == sshconstants.SSH_MSG_KEXDH_REPLY:
|
|
415
|
+
self._handle_kexdh_reply(packet)
|
|
416
|
+
elif msg_id == sshconstants.SSH_MSG_NEWKEYS:
|
|
417
|
+
logger.info("Received SSH_MSG_NEWKEYS")
|
|
418
|
+
# Server NEWKEYS is the last cleartext message; activate the inbound
|
|
419
|
+
# cipher/MAC now so subsequent server traffic decrypts, then signal
|
|
420
|
+
# that key exchange is complete.
|
|
421
|
+
self._activate_incoming()
|
|
422
|
+
self.kex_complete.set()
|
|
423
|
+
elif msg_id == sshconstants.SSH_MSG_DISCONNECT:
|
|
424
|
+
self.close()
|
|
425
|
+
elif msg_id == sshconstants.SSH_MSG_IGNORE:
|
|
426
|
+
pass
|
|
427
|
+
elif msg_id == sshconstants.SSH_MSG_DEBUG:
|
|
428
|
+
pass
|
|
429
|
+
else:
|
|
430
|
+
# Deliver application packets (auth, then the connection layer) to
|
|
431
|
+
# the registered handler; fall back to the queue if none is set.
|
|
432
|
+
handler = self._message_handler
|
|
433
|
+
if handler is not None:
|
|
434
|
+
handler(packet)
|
|
435
|
+
else:
|
|
436
|
+
with self.lock:
|
|
437
|
+
self.packet_queue.append(packet)
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
def register_handler(self, handler):
|
|
441
|
+
"""
|
|
442
|
+
Register a callable(packet) to receive non-transport (application)
|
|
443
|
+
messages. Replaces any previously registered handler.
|
|
444
|
+
"""
|
|
445
|
+
self._message_handler = handler
|
|
446
|
+
return
|
|
447
|
+
|
|
448
|
+
def _handle_kex_init(self, packet):
|
|
449
|
+
self.kex_init_rcvd = True
|
|
450
|
+
logger.info("Received KEXINIT")
|
|
451
|
+
|
|
452
|
+
# Store for hash H
|
|
453
|
+
# Packet payload contains the bytes properly (with msg_id at 0 if unpacked directly?)
|
|
454
|
+
# My _read_packet returns Packet(payload=...). Payload starts with message_id.
|
|
455
|
+
self.server_kex_init_payload = packet.payload
|
|
456
|
+
|
|
457
|
+
# Parse server proposals (simplified, assuming we match)
|
|
458
|
+
# Skipping parsing for now, assuming server speaks Group14 + ssh-rsa/ed25519
|
|
459
|
+
|
|
460
|
+
self._negotiate_algorithms()
|
|
461
|
+
|
|
462
|
+
def _negotiate_algorithms(self):
|
|
463
|
+
# In a real impl, we match lists. Here we assume success.
|
|
464
|
+
# Send KEXDH INIT
|
|
465
|
+
self._send_kexdh_init()
|
|
466
|
+
|
|
467
|
+
def _send_kexdh_init(self):
|
|
468
|
+
logger.info("Sending KEXDH_INIT")
|
|
469
|
+
# Generate DH key pair (Group 14: 2048-bit MODP)
|
|
470
|
+
params = dh.DHParameterNumbers(p=int('FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1'
|
|
471
|
+
'29024E088A67CC74020BBEA63B139B22514A08798E3404DD'
|
|
472
|
+
'EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245'
|
|
473
|
+
'E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED'
|
|
474
|
+
'EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D'
|
|
475
|
+
'C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F'
|
|
476
|
+
'83655D23DCA3AD961C62F356208552BB9ED529077096966D'
|
|
477
|
+
'670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B'
|
|
478
|
+
'E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9'
|
|
479
|
+
'DE2BCBF6955817183995497CEA956AE515D2261898FA0510'
|
|
480
|
+
'15728E5A8AACAA68FFFFFFFFFFFFFFFF', 16),
|
|
481
|
+
g=2)
|
|
482
|
+
self.dh_parameters = params.parameters(default_backend())
|
|
483
|
+
self.dh_private_key = self.dh_parameters.generate_private_key()
|
|
484
|
+
self.dh_public_key = self.dh_private_key.public_key()
|
|
485
|
+
|
|
486
|
+
e = self.dh_public_key.public_numbers().y
|
|
487
|
+
|
|
488
|
+
# Send packet
|
|
489
|
+
pkt = SshPacket(sshconstants.SSH_MSG_KEXDH_INIT)
|
|
490
|
+
pkt.add_mpint(e)
|
|
491
|
+
self.send_packet(pkt)
|
|
492
|
+
|
|
493
|
+
def _handle_kexdh_reply(self, packet: SshPacket):
|
|
494
|
+
logger.info("Received KEXDH_REPLY")
|
|
495
|
+
|
|
496
|
+
# Consume the message-id byte (payload[0]) before parsing the fields.
|
|
497
|
+
packet.get_byte()
|
|
498
|
+
|
|
499
|
+
# 1. Parse packet
|
|
500
|
+
# K_S: server public host key and certificates (K_S)
|
|
501
|
+
host_key_bytes = packet.get_string()
|
|
502
|
+
# f: server public host key (f)
|
|
503
|
+
server_f = packet.get_mpint()
|
|
504
|
+
# s: signature of H
|
|
505
|
+
signature_blob = packet.get_string()
|
|
506
|
+
|
|
507
|
+
# 2. Parse Host Key
|
|
508
|
+
self.host_key = self._parse_host_key(host_key_bytes)
|
|
509
|
+
if not self.host_key:
|
|
510
|
+
raise Exception("Unsupported host key type")
|
|
511
|
+
|
|
512
|
+
# 3. Compute Shared Secret K
|
|
513
|
+
self.shared_secret_K = self._compute_shared_secret(server_f)
|
|
514
|
+
|
|
515
|
+
# 4. Compute Exchange Hash H
|
|
516
|
+
# Client e is needed.
|
|
517
|
+
client_e = self.dh_public_key.public_numbers().y
|
|
518
|
+
|
|
519
|
+
self.exchange_hash_H = self._compute_exchange_hash(
|
|
520
|
+
host_key_bytes,
|
|
521
|
+
client_e,
|
|
522
|
+
server_f,
|
|
523
|
+
self.shared_secret_K
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
# 5. Verify Signature
|
|
527
|
+
# Signature blob structure: string algo, string sig
|
|
528
|
+
# We need to parse it to get the raw signature bytes if needed,
|
|
529
|
+
# but our Keys class verify might expect raw bytes?
|
|
530
|
+
# RFC 4253: "The signature is produced using the private host key...
|
|
531
|
+
# The signature format is defined in section 6.6."
|
|
532
|
+
# Section 6.6: string "ssh-rsa", string rsa_signature_blob.
|
|
533
|
+
# So signature_blob IS the structure (algo, blob).
|
|
534
|
+
|
|
535
|
+
# We need to extract the raw signature from the blob.
|
|
536
|
+
# Let's peek at the algo.
|
|
537
|
+
# Helper to parse signature blob
|
|
538
|
+
sig_pkt = SshPacket(payload=signature_blob)
|
|
539
|
+
sig_algo = sig_pkt.get_string().decode('utf-8')
|
|
540
|
+
raw_sig = sig_pkt.get_string()
|
|
541
|
+
|
|
542
|
+
if sig_algo != self.host_key.get_name():
|
|
543
|
+
raise Exception(f"Signature algorithm mismatch: {sig_algo} vs {self.host_key.get_name()}")
|
|
544
|
+
|
|
545
|
+
if not self.host_key.verify(self.exchange_hash_H, raw_sig):
|
|
546
|
+
raise Exception("Host Key Verification Failed!")
|
|
547
|
+
|
|
548
|
+
logger.info("Host key signature verified")
|
|
549
|
+
|
|
550
|
+
# The signature only proves the peer holds the private key for the
|
|
551
|
+
# presented host key; it does NOT prove the key is the one we expect.
|
|
552
|
+
# Consult the trust verifier (known_hosts / policy) before proceeding,
|
|
553
|
+
# otherwise a man-in-the-middle presenting its own key would be accepted.
|
|
554
|
+
self._verify_host_key_trust(host_key_bytes)
|
|
555
|
+
|
|
556
|
+
# 6. Send NEWKEYS
|
|
557
|
+
self._send_newkeys()
|
|
558
|
+
|
|
559
|
+
def _verify_host_key_trust(self, host_key_bytes: bytes):
|
|
560
|
+
"""
|
|
561
|
+
Confirm the server's host key is trusted for this host. The signature
|
|
562
|
+
check proves key ownership only; this establishes key identity and is
|
|
563
|
+
the defense against a man-in-the-middle.
|
|
564
|
+
"""
|
|
565
|
+
verifier = self.host_key_verifier
|
|
566
|
+
if verifier is None:
|
|
567
|
+
err_msg = ("No host-key trust verifier configured; refusing to trust the "
|
|
568
|
+
"server host key. Configure a host-key policy on the client.")
|
|
569
|
+
raise Exception(err_msg)
|
|
570
|
+
|
|
571
|
+
key_type = self.host_key.get_name()
|
|
572
|
+
trusted = verifier(self.hostname, key_type, host_key_bytes)
|
|
573
|
+
if trusted is not True:
|
|
574
|
+
err_msg = "Server host key for '{}' ({}) is not trusted.".format(self.hostname, key_type)
|
|
575
|
+
raise Exception(err_msg)
|
|
576
|
+
|
|
577
|
+
logger.info("Server host key is trusted for %s", self.hostname)
|
|
578
|
+
return
|
|
579
|
+
|
|
580
|
+
def _parse_host_key(self, data: bytes):
|
|
581
|
+
# Allow parsing of supported keys
|
|
582
|
+
# Data is: string key_type, ...
|
|
583
|
+
pkt = SshPacket(payload=data)
|
|
584
|
+
key_type = pkt.get_string().decode('utf-8')
|
|
585
|
+
|
|
586
|
+
if key_type == 'ssh-rsa':
|
|
587
|
+
e = pkt.get_mpint()
|
|
588
|
+
n = pkt.get_mpint()
|
|
589
|
+
# Construct RSA Key
|
|
590
|
+
pub_numbers = rsa.RSAPublicNumbers(e, n)
|
|
591
|
+
pub_key = pub_numbers.public_key(default_backend())
|
|
592
|
+
return SshRSAKey(pub_key=pub_key)
|
|
593
|
+
elif key_type == 'ssh-ed25519':
|
|
594
|
+
key_bytes = pkt.get_string()
|
|
595
|
+
pub_key = ed25519.Ed25519PublicKey.from_public_bytes(key_bytes)
|
|
596
|
+
return SshEd25519Key(pub_key=pub_key)
|
|
597
|
+
else:
|
|
598
|
+
logger.warning(f"Unknown host key type: {key_type}")
|
|
599
|
+
return None
|
|
600
|
+
|
|
601
|
+
def _compute_shared_secret(self, server_f: int) -> int:
|
|
602
|
+
# K = f^x mod p
|
|
603
|
+
# cryptography DH object doing exchange
|
|
604
|
+
|
|
605
|
+
# Check f range [1, p-1]
|
|
606
|
+
p = self.dh_parameters.parameter_numbers().p
|
|
607
|
+
if not (1 <= server_f < p):
|
|
608
|
+
raise Exception("DH public value f out of range")
|
|
609
|
+
|
|
610
|
+
# Compute shared key
|
|
611
|
+
# cryptography requires the peer public key as object
|
|
612
|
+
# but we only have the int y (f).
|
|
613
|
+
# We reconstruct DHPublicKey.
|
|
614
|
+
|
|
615
|
+
# Note: parameter_numbers() returns full p/g/q if available?
|
|
616
|
+
# Group 14 p and g are known.
|
|
617
|
+
|
|
618
|
+
peer_public_numbers = dh.DHPublicNumbers(server_f, self.dh_parameters.parameter_numbers())
|
|
619
|
+
peer_public_key = peer_public_numbers.public_key(default_backend())
|
|
620
|
+
|
|
621
|
+
shared_key = self.dh_private_key.exchange(peer_public_key)
|
|
622
|
+
|
|
623
|
+
# "K is stored as a mpint" -> The shared secret K IS the result of exchange,
|
|
624
|
+
# but SSH treats it as mpint (signed).
|
|
625
|
+
# Cryptography returns bytes. We need to convert bytes to int.
|
|
626
|
+
|
|
627
|
+
K_int = int.from_bytes(shared_key, byteorder='big')
|
|
628
|
+
return K_int
|
|
629
|
+
|
|
630
|
+
def _compute_exchange_hash(self, host_key_bytes, client_e, server_f, shared_secret_K):
|
|
631
|
+
# H = hash(V_C || V_S || I_C || I_S || K_S || e || f || K)
|
|
632
|
+
# V_C: Client ID string (CR, LF excluded if present? No, raw string sent)
|
|
633
|
+
# RFC 4253: V_C is the client's identification string (CR and LF excluded).
|
|
634
|
+
# My self.local_version is "SSH-2.0-..."
|
|
635
|
+
# self.remote_version is also stripped.
|
|
636
|
+
|
|
637
|
+
# H calculation requires packing data into a buffer.
|
|
638
|
+
h_pkt = SshPacket()
|
|
639
|
+
h_pkt.add_string(self.local_version)
|
|
640
|
+
h_pkt.add_string(self.remote_version)
|
|
641
|
+
h_pkt.add_string(self.client_kex_init_payload) # I_C
|
|
642
|
+
h_pkt.add_string(self.server_kex_init_payload) # I_S
|
|
643
|
+
h_pkt.add_string(host_key_bytes) # K_S
|
|
644
|
+
h_pkt.add_mpint(client_e) # e
|
|
645
|
+
h_pkt.add_mpint(server_f) # f
|
|
646
|
+
h_pkt.add_mpint(shared_secret_K) # K
|
|
647
|
+
|
|
648
|
+
# Hash SHA1 (for Group14-SHA1)
|
|
649
|
+
digest = hashlib.sha1(h_pkt.payload).digest()
|
|
650
|
+
return digest
|
|
651
|
+
|
|
652
|
+
def _ensure_session_id(self):
|
|
653
|
+
# On the first key exchange the session id is the exchange hash H; it is
|
|
654
|
+
# fixed for the life of the connection even across later rekeys.
|
|
655
|
+
if self.session_id is None:
|
|
656
|
+
self.session_id = self.exchange_hash_H
|
|
657
|
+
return
|
|
658
|
+
|
|
659
|
+
def _activate_outgoing(self):
|
|
660
|
+
"""
|
|
661
|
+
Activate the client->server cipher and MAC. Called immediately after the
|
|
662
|
+
client sends its NEWKEYS, so subsequent outbound packets are encrypted.
|
|
663
|
+
Keys 'C'/'A'/'E' per RFC 4253 section 7.2.
|
|
664
|
+
"""
|
|
665
|
+
self._ensure_session_id()
|
|
666
|
+
|
|
667
|
+
key_len = 16
|
|
668
|
+
iv_len = 16
|
|
669
|
+
c2s_iv = self._compute_key(b'A', iv_len)
|
|
670
|
+
c2s_key = self._compute_key(b'C', key_len)
|
|
671
|
+
c2s_mac_key = self._compute_key(b'E', 20)
|
|
672
|
+
|
|
673
|
+
cipher_enc = Cipher(algorithms.AES(c2s_key), modes.CTR(c2s_iv), backend=default_backend())
|
|
674
|
+
self.encryptor = cipher_enc.encryptor()
|
|
675
|
+
self.hmac_out_key = c2s_mac_key
|
|
676
|
+
self.mac_len_out = 20
|
|
677
|
+
|
|
678
|
+
logger.info("Outbound encryption activated (AES-128-CTR + HMAC-SHA1)")
|
|
679
|
+
return
|
|
680
|
+
|
|
681
|
+
def _activate_incoming(self):
|
|
682
|
+
"""
|
|
683
|
+
Activate the server->client cipher and MAC. Called after the client
|
|
684
|
+
receives the server's NEWKEYS, so subsequent inbound packets decrypt.
|
|
685
|
+
Keys 'D'/'B'/'F' per RFC 4253 section 7.2.
|
|
686
|
+
"""
|
|
687
|
+
self._ensure_session_id()
|
|
688
|
+
|
|
689
|
+
key_len = 16
|
|
690
|
+
iv_len = 16
|
|
691
|
+
s2c_iv = self._compute_key(b'B', iv_len)
|
|
692
|
+
s2c_key = self._compute_key(b'D', key_len)
|
|
693
|
+
s2c_mac_key = self._compute_key(b'F', 20)
|
|
694
|
+
|
|
695
|
+
cipher_dec = Cipher(algorithms.AES(s2c_key), modes.CTR(s2c_iv), backend=default_backend())
|
|
696
|
+
self.decryptor = cipher_dec.decryptor()
|
|
697
|
+
self.hmac_in_key = s2c_mac_key
|
|
698
|
+
self.mac_len_in = 20
|
|
699
|
+
|
|
700
|
+
logger.info("Inbound encryption activated (AES-128-CTR + HMAC-SHA1)")
|
|
701
|
+
return
|
|
702
|
+
|
|
703
|
+
def _compute_key(self, id_byte: bytes, key_len: int) -> bytes:
|
|
704
|
+
"""
|
|
705
|
+
Derive key of length 'key_len' using id_byte.
|
|
706
|
+
K = HASH(K || H || id_byte || session_id)
|
|
707
|
+
Extend if needed.
|
|
708
|
+
"""
|
|
709
|
+
# K is mpint. Need bytes.
|
|
710
|
+
# Helper to encode mpint as bytes (including length field)?
|
|
711
|
+
# RFC 4253: "K is encoded as mpint".
|
|
712
|
+
# But for hash input, it is just the mpint bytes?
|
|
713
|
+
# RFC 4253 Section 5: "Representation of mpints in the hash..."
|
|
714
|
+
# It says: "The shared secret K is encoded as a mpint".
|
|
715
|
+
# So it includes the 4-byte length.
|
|
716
|
+
|
|
717
|
+
def to_mpint_bytes(n):
|
|
718
|
+
# Same logic as Packet.add_mpint but return bytes
|
|
719
|
+
l = n.bit_length()
|
|
720
|
+
if l == 0:
|
|
721
|
+
return b'\x00\x00\x00\x00'
|
|
722
|
+
byte_len = (l + 8) // 8
|
|
723
|
+
val_bytes = n.to_bytes(byte_len, 'big', signed=True)
|
|
724
|
+
return struct.pack('>I', len(val_bytes)) + val_bytes
|
|
725
|
+
|
|
726
|
+
K_bytes = to_mpint_bytes(self.shared_secret_K)
|
|
727
|
+
H_bytes = self.exchange_hash_H
|
|
728
|
+
|
|
729
|
+
# Hash 1
|
|
730
|
+
h = hashlib.sha1()
|
|
731
|
+
h.update(K_bytes)
|
|
732
|
+
h.update(H_bytes)
|
|
733
|
+
h.update(id_byte)
|
|
734
|
+
h.update(self.session_id)
|
|
735
|
+
digest = h.digest()
|
|
736
|
+
|
|
737
|
+
result = digest
|
|
738
|
+
|
|
739
|
+
# Extend if needed
|
|
740
|
+
while len(result) < key_len:
|
|
741
|
+
h = hashlib.sha1()
|
|
742
|
+
h.update(K_bytes)
|
|
743
|
+
h.update(H_bytes)
|
|
744
|
+
h.update(result)
|
|
745
|
+
result += h.digest()
|
|
746
|
+
|
|
747
|
+
return result[:key_len]
|
|
748
|
+
|
|
749
|
+
def _send_newkeys(self):
|
|
750
|
+
pkt = SshPacket(sshconstants.SSH_MSG_NEWKEYS)
|
|
751
|
+
self.send_packet(pkt)
|
|
752
|
+
logger.info("Sent NEWKEYS")
|
|
753
|
+
# Everything the client sends after its NEWKEYS must be encrypted, so
|
|
754
|
+
# activate the outbound direction now. The inbound direction is activated
|
|
755
|
+
# only when the server's NEWKEYS arrives (see _process_packet).
|
|
756
|
+
self._activate_outgoing()
|