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,197 @@
1
+ """
2
+ SSH Keys Interface
3
+ """
4
+ from abc import ABC, abstractmethod
5
+ from cryptography.hazmat.primitives import serialization, hashes
6
+ from cryptography.hazmat.primitives.asymmetric import rsa, ed25519, padding
7
+ from cryptography.exceptions import InvalidSignature
8
+
9
+ class HostKey(ABC):
10
+ """
11
+ Abstract base class for Host Keys.
12
+ """
13
+
14
+ @abstractmethod
15
+ def get_name(self) -> str:
16
+ """
17
+ Return algorithm name (e.g. ssh-rsa)
18
+ """
19
+ pass
20
+
21
+ @abstractmethod
22
+ def sign(self, data: bytes) -> bytes:
23
+ """
24
+ Sign data.
25
+ """
26
+ pass
27
+
28
+ @abstractmethod
29
+ def verify(self, data: bytes, signature: bytes) -> bool:
30
+ """
31
+ Verify signature.
32
+ """
33
+ pass
34
+
35
+ @abstractmethod
36
+ def as_bytes(self) -> bytes:
37
+ """
38
+ Return public key as bytes for transmission (OpenSSH format).
39
+ """
40
+ pass
41
+
42
+ class SshRSAKey(HostKey):
43
+ """
44
+ RSA Key Implementation using cryptography.
45
+ """
46
+ def __init__(self, key: rsa.RSAPrivateKey = None, pub_key: rsa.RSAPublicKey = None):
47
+ if key:
48
+ self.private_key = key
49
+ self.public_key = key.public_key()
50
+ elif pub_key:
51
+ self.private_key = None
52
+ self.public_key = pub_key
53
+ else:
54
+ # Generate a new key for testing if none provided?
55
+ # Or raise error? For now, let's generate 2048-bit key if nothing passed.
56
+ self.private_key = rsa.generate_private_key(
57
+ public_exponent=65537,
58
+ key_size=2048
59
+ )
60
+ self.public_key = self.private_key.public_key()
61
+
62
+ @classmethod
63
+ def from_private_key_file(cls, filename, password=None):
64
+ with open(filename, "rb") as f:
65
+ data = f.read()
66
+ return cls.from_private_key(data, password)
67
+
68
+ @classmethod
69
+ def from_private_key(cls, data, password=None):
70
+ # Handle file-like object
71
+ if hasattr(data, "read"):
72
+ data = data.read()
73
+
74
+ if isinstance(data, str):
75
+ data = data.encode('utf-8')
76
+
77
+ p_key = serialization.load_pem_private_key(
78
+ data,
79
+ password=password.encode() if isinstance(password, str) else password
80
+ )
81
+ if not isinstance(p_key, rsa.RSAPrivateKey):
82
+ raise ValueError("Not an RSA key")
83
+
84
+ return cls(key=p_key)
85
+
86
+ def get_name(self) -> str:
87
+ return "ssh-rsa"
88
+
89
+ def sign(self, data: bytes) -> bytes:
90
+ if not self.private_key:
91
+ raise ValueError("Private key not available for signing")
92
+
93
+ return self.private_key.sign(
94
+ data,
95
+ padding.PKCS1v15(),
96
+ hashes.SHA1()
97
+ )
98
+
99
+ def verify(self, data: bytes, signature: bytes) -> bool:
100
+ try:
101
+ self.public_key.verify(
102
+ signature,
103
+ data,
104
+ padding.PKCS1v15(),
105
+ hashes.SHA1()
106
+ )
107
+ return True
108
+ except InvalidSignature:
109
+ return False
110
+
111
+ def as_bytes(self) -> bytes:
112
+ """
113
+ Format: string "ssh-rsa", mpint e, mpint n
114
+ """
115
+ # We need to manually construct the blob because cryptography's PublicFormat.OpenSSH
116
+ # includes the header "ssh-rsa <b64> <comment>", but we just want the inner blob
117
+ # or we want the wire format components.
118
+ # The wire format is: string algorithm, mpint e, mpint n.
119
+
120
+ numbers = self.public_key.public_numbers()
121
+
122
+ from ccai.protocols.ssh.sshpacket import SshPacket
123
+ pkt = SshPacket()
124
+ pkt.add_string(self.get_name())
125
+ pkt.add_mpint(numbers.e)
126
+ pkt.add_mpint(numbers.n)
127
+ return pkt.payload
128
+
129
+ class SshEd25519Key(HostKey):
130
+ """
131
+ Ed25519 Key Implementation.
132
+ """
133
+ def __init__(self, key: ed25519.Ed25519PrivateKey = None, pub_key: ed25519.Ed25519PublicKey = None):
134
+ if key:
135
+ self.private_key = key
136
+ self.public_key = key.public_key()
137
+ elif pub_key:
138
+ self.private_key = None
139
+ self.public_key = pub_key
140
+ else:
141
+ self.private_key = ed25519.Ed25519PrivateKey.generate()
142
+ self.public_key = self.private_key.public_key()
143
+
144
+ @classmethod
145
+ def from_private_key_file(cls, filename, password=None):
146
+ with open(filename, "rb") as f:
147
+ data = f.read()
148
+ return cls.from_private_key(data, password)
149
+
150
+ @classmethod
151
+ def from_private_key(cls, data, password=None):
152
+ # Handle file-like object
153
+ if hasattr(data, "read"):
154
+ data = data.read()
155
+
156
+ if isinstance(data, str):
157
+ data = data.encode('utf-8')
158
+
159
+ p_key = serialization.load_pem_private_key(
160
+ data,
161
+ password=password.encode() if isinstance(password, str) else password
162
+ )
163
+ if not isinstance(p_key, ed25519.Ed25519PrivateKey):
164
+ raise ValueError("Not an Ed25519 key")
165
+
166
+ return cls(key=p_key)
167
+
168
+ def get_name(self) -> str:
169
+ return "ssh-ed25519"
170
+
171
+ def sign(self, data: bytes) -> bytes:
172
+ if not self.private_key:
173
+ raise ValueError("Private key not available for signing")
174
+ return self.private_key.sign(data)
175
+
176
+ def verify(self, data: bytes, signature: bytes) -> bool:
177
+ try:
178
+ self.public_key.verify(signature, data)
179
+ return True
180
+ except InvalidSignature:
181
+ return False
182
+
183
+ def as_bytes(self) -> bytes:
184
+ """
185
+ Format: string "ssh-ed25519", string key_bytes
186
+ """
187
+ # Get raw bytes
188
+ pub_bytes = self.public_key.public_bytes(
189
+ encoding=serialization.Encoding.Raw,
190
+ format=serialization.PublicFormat.Raw
191
+ )
192
+
193
+ from ccai.protocols.ssh.sshpacket import SshPacket
194
+ pkt = SshPacket()
195
+ pkt.add_string(self.get_name())
196
+ pkt.add_string(pub_bytes)
197
+ return pkt.payload
@@ -0,0 +1,138 @@
1
+ """
2
+ SSH Packet Handling
3
+ """
4
+
5
+ import struct
6
+ from typing import Union, List
7
+
8
+ class SshPacket:
9
+ """
10
+ Repsrefents an SSH Packet and provides methods for packing/unpacking data types.
11
+ """
12
+
13
+ def __init__(self, message_id: int = None, payload: bytes = b''):
14
+ self.message_id = message_id
15
+ self.payload = bytearray(payload)
16
+ self._position = 0
17
+
18
+ @property
19
+ def remaining(self) -> bytes:
20
+ return self.payload[self._position:]
21
+
22
+ def get_bytes(self, length: int) -> bytes:
23
+ if self._position + length > len(self.payload):
24
+ raise IndexError("Not enough data in packet")
25
+ data = self.payload[self._position : self._position + length]
26
+ self._position += length
27
+ return bytes(data)
28
+
29
+ def get_byte(self) -> int:
30
+ return self.get_bytes(1)[0]
31
+
32
+ def get_boolean(self) -> bool:
33
+ return bool(self.get_byte())
34
+
35
+ def get_uint32(self) -> int:
36
+ return struct.unpack('>I', self.get_bytes(4))[0]
37
+
38
+ def get_uint64(self) -> int:
39
+ return struct.unpack('>Q', self.get_bytes(8))[0]
40
+
41
+ def get_string(self) -> bytes:
42
+ """
43
+ SSH string: uint32 length + content
44
+ """
45
+ length = self.get_uint32()
46
+ return self.get_bytes(length)
47
+
48
+ def get_mpint(self) -> int:
49
+ """
50
+ SSH mpint: Two's complement integer, stored as a string.
51
+ """
52
+ data = self.get_string()
53
+ if not data:
54
+ return 0
55
+ return int.from_bytes(data, byteorder='big', signed=True)
56
+
57
+ def get_name_list(self) -> List[str]:
58
+ """
59
+ SSH name-list: comma-separated list of strings
60
+ """
61
+ data = self.get_string().decode('utf-8')
62
+ if not data:
63
+ return []
64
+ return data.split(',')
65
+
66
+ # --- Packing Methods ---
67
+
68
+ def add_byte(self, b: int):
69
+ self.payload.append(b & 0xFF)
70
+
71
+ def add_bytes(self, data: bytes):
72
+ """
73
+ Append raw bytes with no length prefix (e.g. the KEXINIT cookie).
74
+ """
75
+ self.payload.extend(data)
76
+
77
+ def add_boolean(self, b: bool):
78
+ self.add_byte(1 if b else 0)
79
+
80
+ def add_uint32(self, n: int):
81
+ self.payload.extend(struct.pack('>I', n))
82
+
83
+ def add_uint64(self, n: int):
84
+ self.payload.extend(struct.pack('>Q', n))
85
+
86
+ def add_string(self, s: Union[str, bytes]):
87
+ if isinstance(s, str):
88
+ s = s.encode('utf-8')
89
+ self.add_uint32(len(s))
90
+ self.payload.extend(s)
91
+
92
+ def add_mpint(self, n: int):
93
+ if n == 0:
94
+ self.add_uint32(0)
95
+ return
96
+
97
+ # Calculate number of bytes needed
98
+ # bit_length() excludes sign, so we might need an extra bit/byte
99
+ length = (n.bit_length() + 7) // 8
100
+ try:
101
+ b = n.to_bytes(length, byteorder='big', signed=True)
102
+ except OverflowError:
103
+ length += 1
104
+ b = n.to_bytes(length, byteorder='big', signed=True)
105
+
106
+ self.add_string(b) # mpint is stored as a string
107
+
108
+ def add_name_list(self, names: List[str]):
109
+ self.add_string(",".join(names))
110
+
111
+ def to_bytes(self) -> bytes:
112
+ """
113
+ Returns the full payload with message ID if set.
114
+ Usually the message ID is the first byte of the payload.
115
+ """
116
+ if self.message_id is not None:
117
+ # Prepend message ID if not already in payload?
118
+ # Standard practice: explicit message ID + payloads.
119
+ # but often we build the packet linearly.
120
+ # If message_id is set in constructor, we assume it's the first byte.
121
+ return struct.pack('B', self.message_id) + self.payload
122
+ return bytes(self.payload)
123
+
124
+ @classmethod
125
+ def create_disconnect(cls, reason_code: int, description: str):
126
+ from ccai.protocols.ssh import sshconstants as constants
127
+ pkt = cls(constants.SSH_MSG_DISCONNECT)
128
+ pkt.add_uint32(reason_code)
129
+ pkt.add_string(description)
130
+ pkt.add_string("") # Language tag
131
+ return pkt
132
+
133
+ @classmethod
134
+ def create_ignore(cls, data: bytes):
135
+ from ccai.protocols.ssh import sshconstants as constants
136
+ pkt = cls(constants.SSH_MSG_IGNORE)
137
+ pkt.add_string(data)
138
+ return pkt