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,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
|