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/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ccssh - a from-scratch SSH client in Python (sync + async + CLI).
|
|
3
|
+
|
|
4
|
+
Native transport on top of `cryptography` primitives; verified against
|
|
5
|
+
real OpenSSH servers (see tests/test_live_sshd.py), including the 10.6
|
|
6
|
+
ssh-mldsa44-ed25519 composite signatures.
|
|
7
|
+
|
|
8
|
+
Algorithm parity (see ccssh.algs for the honest matrix):
|
|
9
|
+
KEX 14/16, ciphers 6/10, MACs 6/16, host keys 8/16 + certs with CA verify.
|
|
10
|
+
"""
|
|
11
|
+
try:
|
|
12
|
+
from ccssh.async_client import AsyncCCSSHClient
|
|
13
|
+
from ccssh.client import CCSSHClient, AuthError, HostKeyError, CCSSHError, ShellSession
|
|
14
|
+
from ccssh.transport import CCSSHTimeoutError
|
|
15
|
+
except ImportError as _e:
|
|
16
|
+
if "cryptography" in str(_e).lower():
|
|
17
|
+
raise ImportError(f"ccssh needs a working 'cryptography' package, but it failed to import ({_e}).") from _e
|
|
18
|
+
raise
|
|
19
|
+
|
|
20
|
+
__all__ = ["CCSSHClient", "AsyncCCSSHClient", "AuthError", "HostKeyError", "CCSSHError", "CCSSHTimeoutError", "ShellSession"]
|
|
21
|
+
__version__ = "1.0.0"
|
ccssh/__main__.py
ADDED
ccssh/algs.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Algorithm registry: what ccssh supports vs OpenSSH (from local `ssh -Q`).
|
|
3
|
+
|
|
4
|
+
Honest numbers only: an algorithm counts as supported after loopback AND
|
|
5
|
+
live-interop tests against a real sshd go green. Weak primitives live in
|
|
6
|
+
LEGACY (implemented, never offered unless named explicitly).
|
|
7
|
+
(FIDO2 login additionally needs a
|
|
8
|
+
hardware token at runtime - see ccssh.sk; all of its software side is
|
|
9
|
+
implemented and tested.)
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import threading
|
|
14
|
+
|
|
15
|
+
# Full OpenSSH 10.6 algorithm set (`ssh -Q`), sorted strongest-first
|
|
16
|
+
# (NOT ssh order): PQ hybrids, then 256-bit ECDH/AEAD, down to 1024-bit
|
|
17
|
+
# group1-sha1 / 3DES / MD5 / ssh-dss-era primitives.
|
|
18
|
+
OPENSSH_KEX = [
|
|
19
|
+
"mlkem768x25519-sha256",
|
|
20
|
+
"sntrup761x25519-sha512",
|
|
21
|
+
"sntrup761x25519-sha512@openssh.com",
|
|
22
|
+
"mlkem768nistp256-sha256",
|
|
23
|
+
"curve25519-sha256",
|
|
24
|
+
"curve25519-sha256@libssh.org",
|
|
25
|
+
"ecdh-sha2-nistp521",
|
|
26
|
+
"ecdh-sha2-nistp384",
|
|
27
|
+
"ecdh-sha2-nistp256",
|
|
28
|
+
"diffie-hellman-group18-sha512",
|
|
29
|
+
"diffie-hellman-group16-sha512",
|
|
30
|
+
"diffie-hellman-group-exchange-sha256",
|
|
31
|
+
"diffie-hellman-group14-sha256",
|
|
32
|
+
"diffie-hellman-group14-sha1",
|
|
33
|
+
"diffie-hellman-group-exchange-sha1",
|
|
34
|
+
"diffie-hellman-group1-sha1",
|
|
35
|
+
]
|
|
36
|
+
OPENSSH_CIPHERS = [
|
|
37
|
+
"aes256-gcm@openssh.com",
|
|
38
|
+
"chacha20-poly1305@openssh.com",
|
|
39
|
+
"aes128-gcm@openssh.com",
|
|
40
|
+
"aes256-ctr",
|
|
41
|
+
"aes192-ctr",
|
|
42
|
+
"aes128-ctr",
|
|
43
|
+
"aes256-cbc",
|
|
44
|
+
"aes192-cbc",
|
|
45
|
+
"aes128-cbc",
|
|
46
|
+
"3des-cbc",
|
|
47
|
+
]
|
|
48
|
+
OPENSSH_MACS = [
|
|
49
|
+
"hmac-sha2-512-etm@openssh.com",
|
|
50
|
+
"hmac-sha2-256-etm@openssh.com",
|
|
51
|
+
"umac-128-etm@openssh.com",
|
|
52
|
+
"hmac-sha1-etm@openssh.com",
|
|
53
|
+
"umac-64-etm@openssh.com",
|
|
54
|
+
"hmac-sha1-96-etm@openssh.com",
|
|
55
|
+
"hmac-md5-etm@openssh.com",
|
|
56
|
+
"hmac-md5-96-etm@openssh.com",
|
|
57
|
+
"hmac-sha2-512",
|
|
58
|
+
"hmac-sha2-256",
|
|
59
|
+
"umac-128@openssh.com",
|
|
60
|
+
"hmac-sha1",
|
|
61
|
+
"umac-64@openssh.com",
|
|
62
|
+
"hmac-sha1-96",
|
|
63
|
+
"hmac-md5",
|
|
64
|
+
"hmac-md5-96",
|
|
65
|
+
]
|
|
66
|
+
OPENSSH_KEYS = [
|
|
67
|
+
"ssh-mldsa44-ed25519",
|
|
68
|
+
"ssh-mldsa44-ed25519-cert",
|
|
69
|
+
"ssh-ed25519",
|
|
70
|
+
"ssh-ed25519-cert-v01@openssh.com",
|
|
71
|
+
"ecdsa-sha2-nistp521",
|
|
72
|
+
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
|
|
73
|
+
"ecdsa-sha2-nistp384",
|
|
74
|
+
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
|
|
75
|
+
"ecdsa-sha2-nistp256",
|
|
76
|
+
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
|
|
77
|
+
"sk-ssh-ed25519@openssh.com",
|
|
78
|
+
"sk-ssh-ed25519-cert-v01@openssh.com",
|
|
79
|
+
"sk-ecdsa-sha2-nistp256@openssh.com",
|
|
80
|
+
"sk-ecdsa-sha2-nistp256-cert-v01@openssh.com",
|
|
81
|
+
"ssh-rsa",
|
|
82
|
+
"ssh-rsa-cert-v01@openssh.com",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
"""
|
|
86
|
+
Actually implemented and covered by loopback + live tests.
|
|
87
|
+
Order in every list below is strongest-first: negotiation follows our
|
|
88
|
+
preference, so the first common algorithm is the strongest both sides
|
|
89
|
+
support. Tiers: SUPPORTED (offered by default), MAX (strict top tier
|
|
90
|
+
for explicit API use), LEGACY (implemented but never offered unless
|
|
91
|
+
named explicitly - weak, for old servers only).
|
|
92
|
+
"""
|
|
93
|
+
SUPPORTED_KEX = [
|
|
94
|
+
"mlkem768x25519-sha256",
|
|
95
|
+
"sntrup761x25519-sha512",
|
|
96
|
+
"sntrup761x25519-sha512@openssh.com",
|
|
97
|
+
"mlkem768nistp256-sha256",
|
|
98
|
+
"curve25519-sha256",
|
|
99
|
+
"curve25519-sha256@libssh.org",
|
|
100
|
+
"ecdh-sha2-nistp521",
|
|
101
|
+
"ecdh-sha2-nistp384",
|
|
102
|
+
"ecdh-sha2-nistp256",
|
|
103
|
+
"diffie-hellman-group18-sha512",
|
|
104
|
+
"diffie-hellman-group16-sha512",
|
|
105
|
+
"diffie-hellman-group-exchange-sha256",
|
|
106
|
+
"diffie-hellman-group14-sha256",
|
|
107
|
+
"diffie-hellman-group14-sha1"
|
|
108
|
+
]
|
|
109
|
+
SUPPORTED_CIPHERS = [
|
|
110
|
+
"aes256-gcm@openssh.com",
|
|
111
|
+
"aes256-ctr",
|
|
112
|
+
"chacha20-poly1305@openssh.com",
|
|
113
|
+
"aes192-ctr",
|
|
114
|
+
"aes128-gcm@openssh.com",
|
|
115
|
+
"aes128-ctr"
|
|
116
|
+
]
|
|
117
|
+
SUPPORTED_MACS = [
|
|
118
|
+
"hmac-sha2-512-etm@openssh.com",
|
|
119
|
+
"hmac-sha2-256-etm@openssh.com",
|
|
120
|
+
"hmac-sha1-etm@openssh.com",
|
|
121
|
+
"hmac-sha2-512",
|
|
122
|
+
"hmac-sha2-256",
|
|
123
|
+
"hmac-sha1"
|
|
124
|
+
]
|
|
125
|
+
SUPPORTED_HOSTKEYS = [
|
|
126
|
+
"ssh-mldsa44-ed25519",
|
|
127
|
+
"ssh-ed25519",
|
|
128
|
+
"ecdsa-sha2-nistp521",
|
|
129
|
+
"ecdsa-sha2-nistp384",
|
|
130
|
+
"ecdsa-sha2-nistp256",
|
|
131
|
+
"rsa-sha2-512",
|
|
132
|
+
"rsa-sha2-256"
|
|
133
|
+
]
|
|
134
|
+
# Certificate host keys carry full CA verification (see certs.py), not opaque trust.
|
|
135
|
+
|
|
136
|
+
LEGACY_KEX = [
|
|
137
|
+
"diffie-hellman-group-exchange-sha1",
|
|
138
|
+
"diffie-hellman-group1-sha1"
|
|
139
|
+
]
|
|
140
|
+
LEGACY_CIPHERS = [
|
|
141
|
+
"aes256-cbc",
|
|
142
|
+
"aes192-cbc",
|
|
143
|
+
"aes128-cbc",
|
|
144
|
+
"3des-cbc"
|
|
145
|
+
]
|
|
146
|
+
LEGACY_MACS = [
|
|
147
|
+
"hmac-sha1-96-etm@openssh.com",
|
|
148
|
+
"hmac-sha1-96",
|
|
149
|
+
"hmac-md5-etm@openssh.com",
|
|
150
|
+
"hmac-md5",
|
|
151
|
+
"hmac-md5-96-etm@openssh.com",
|
|
152
|
+
"hmac-md5-96",
|
|
153
|
+
"umac-128-etm@openssh.com",
|
|
154
|
+
"umac-128@openssh.com",
|
|
155
|
+
"umac-64-etm@openssh.com",
|
|
156
|
+
"umac-64@openssh.com"
|
|
157
|
+
]
|
|
158
|
+
LEGACY_HOSTKEYS = [
|
|
159
|
+
"ssh-rsa",
|
|
160
|
+
# 1024-bit DSA with SHA-1, removed from OpenSSH 10.x entirely
|
|
161
|
+
# ("Bad key types"): implemented for old-box recovery, never offered
|
|
162
|
+
# unless named explicitly - like every other legacy primitive.
|
|
163
|
+
"ssh-dss",
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
"""
|
|
167
|
+
Strength-ordered strict top tier: only the strongest primitives
|
|
168
|
+
(PQ hybrids, 256-bit ciphers/MACs). Pass one of these lists explicitly
|
|
169
|
+
(API) to offer nothing weaker; negotiation still intersects with the
|
|
170
|
+
server offer, so it fails closed when the server lacks top-tier
|
|
171
|
+
algorithms instead of silently downgrading.
|
|
172
|
+
"""
|
|
173
|
+
MAX_KEX = [
|
|
174
|
+
"mlkem768x25519-sha256",
|
|
175
|
+
"sntrup761x25519-sha512",
|
|
176
|
+
"sntrup761x25519-sha512@openssh.com",
|
|
177
|
+
"mlkem768nistp256-sha256",
|
|
178
|
+
"curve25519-sha256",
|
|
179
|
+
"curve25519-sha256@libssh.org",
|
|
180
|
+
"ecdh-sha2-nistp521",
|
|
181
|
+
"diffie-hellman-group18-sha512"
|
|
182
|
+
]
|
|
183
|
+
MAX_CIPHERS = [
|
|
184
|
+
"aes256-gcm@openssh.com",
|
|
185
|
+
"aes256-ctr",
|
|
186
|
+
"chacha20-poly1305@openssh.com"
|
|
187
|
+
]
|
|
188
|
+
MAX_MACS = [
|
|
189
|
+
"hmac-sha2-512-etm@openssh.com",
|
|
190
|
+
"hmac-sha2-256-etm@openssh.com"
|
|
191
|
+
]
|
|
192
|
+
MAX_HOSTKEYS = [
|
|
193
|
+
"ssh-mldsa44-ed25519",
|
|
194
|
+
"ssh-ed25519",
|
|
195
|
+
"ecdsa-sha2-nistp521",
|
|
196
|
+
"ecdsa-sha2-nistp384",
|
|
197
|
+
"ecdsa-sha2-nistp256",
|
|
198
|
+
"rsa-sha2-512",
|
|
199
|
+
"rsa-sha2-256"
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def normalize_list(base: list[str], value: list[str] | None) -> list[str] | None:
|
|
204
|
+
"""
|
|
205
|
+
Resolve an algorithm list against built-in defaults (OpenSSH rules).
|
|
206
|
+
|
|
207
|
+
``None`` passes through (caller falls back to its default). A plain
|
|
208
|
+
list replaces the defaults. Entries starting with ``+``/``-``/``^``
|
|
209
|
+
are relative to the defaults (append / remove / prepend), exactly
|
|
210
|
+
like ssh_config(5). There is no ``max`` keyword (not in OpenSSH).
|
|
211
|
+
"""
|
|
212
|
+
if value is None:
|
|
213
|
+
return None
|
|
214
|
+
vals = [v.strip() for v in value]
|
|
215
|
+
vals = [v for v in vals if v]
|
|
216
|
+
if any(v[0] in "+-^" for v in vals):
|
|
217
|
+
out = list(base)
|
|
218
|
+
for v in vals:
|
|
219
|
+
if len(v) == 1: # lone +/-/^ is meaningless: fail, don't ignore
|
|
220
|
+
raise ValueError(f"bad algorithm item {v!r}")
|
|
221
|
+
if v.startswith("+"):
|
|
222
|
+
if v[1:] not in out:
|
|
223
|
+
out.append(v[1:])
|
|
224
|
+
elif v.startswith("-"):
|
|
225
|
+
out = [x for x in out if x != v[1:]]
|
|
226
|
+
elif v.startswith("^"):
|
|
227
|
+
out = [v[1:]] + [x for x in out if x != v[1:]]
|
|
228
|
+
elif v not in out:
|
|
229
|
+
out.append(v)
|
|
230
|
+
if not out:
|
|
231
|
+
raise ValueError("algorithm list is empty after +/-/^ expansion")
|
|
232
|
+
return out
|
|
233
|
+
return list(vals)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def expand_alg_option(base: list[str], spec: str) -> list[str]:
|
|
237
|
+
"""
|
|
238
|
+
Resolve one `-o` algorithm option string (OpenSSH-style).
|
|
239
|
+
|
|
240
|
+
A plain comma list replaces the defaults; entries starting with
|
|
241
|
+
``+``/``-``/``^`` are relative to them (append / remove / prepend,
|
|
242
|
+
like ssh_config). There is no ``max`` keyword (not in OpenSSH).
|
|
243
|
+
"""
|
|
244
|
+
vals = [v.strip() for v in spec.split(",")]
|
|
245
|
+
vals = [v for v in vals if v]
|
|
246
|
+
out = normalize_list(base, vals)
|
|
247
|
+
if not out:
|
|
248
|
+
raise ValueError("algorithm option is empty")
|
|
249
|
+
return out
|
|
250
|
+
|
|
251
|
+
_sntrup_ok: bool | None = None
|
|
252
|
+
_SNTRUP_LOCK = threading.Lock()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def sntrup_available() -> bool:
|
|
256
|
+
"""
|
|
257
|
+
Whether sntrup761 can actually run here (liboqs present). Cached;
|
|
258
|
+
safe to call often, never raises.
|
|
259
|
+
"""
|
|
260
|
+
global _sntrup_ok
|
|
261
|
+
if _sntrup_ok is None:
|
|
262
|
+
with _SNTRUP_LOCK:
|
|
263
|
+
if _sntrup_ok is None:
|
|
264
|
+
try:
|
|
265
|
+
from ccssh import sntrup as SN
|
|
266
|
+
SN.load_backend()
|
|
267
|
+
_sntrup_ok = True
|
|
268
|
+
except Exception:
|
|
269
|
+
_sntrup_ok = False
|
|
270
|
+
return _sntrup_ok
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def default_kex() -> list[str]:
|
|
274
|
+
"""
|
|
275
|
+
Default KEX offer: everything supported that can run here. sntrup761
|
|
276
|
+
is included only with a working liboqs, so default users never hit
|
|
277
|
+
BackendMissing; explicit selection still errors clearly without one.
|
|
278
|
+
"""
|
|
279
|
+
if sntrup_available():
|
|
280
|
+
return list(SUPPORTED_KEX)
|
|
281
|
+
return [k for k in SUPPORTED_KEX if "sntrup761" not in k]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def coverage() -> dict[str, str]:
|
|
285
|
+
return {
|
|
286
|
+
"kex": f"{len(SUPPORTED_KEX)}/{len(OPENSSH_KEX)} live (sntrup761 needs liboqs)",
|
|
287
|
+
"cipher": f"{len(SUPPORTED_CIPHERS)}/{len(OPENSSH_CIPHERS)} live",
|
|
288
|
+
"mac": f"{len(SUPPORTED_MACS)}/{len(OPENSSH_MACS)} live (+AEAD implicit)",
|
|
289
|
+
"hostkey": f"{len(SUPPORTED_HOSTKEYS)}/{len(OPENSSH_KEYS)} live (certs with CA verify)",
|
|
290
|
+
}
|