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.
@@ -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()