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 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+ from ccssh.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ sys.exit(main())
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
+ }