ccssh 1.0.0__tar.gz
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-1.0.0/LICENSE +21 -0
- ccssh-1.0.0/MANIFEST.in +3 -0
- ccssh-1.0.0/PKG-INFO +442 -0
- ccssh-1.0.0/README.md +401 -0
- ccssh-1.0.0/ccssh/__init__.py +21 -0
- ccssh-1.0.0/ccssh/__main__.py +5 -0
- ccssh-1.0.0/ccssh/algs.py +290 -0
- ccssh-1.0.0/ccssh/async_client.py +419 -0
- ccssh-1.0.0/ccssh/auth.py +170 -0
- ccssh-1.0.0/ccssh/certs.py +283 -0
- ccssh-1.0.0/ccssh/chacha.py +117 -0
- ccssh-1.0.0/ccssh/channel.py +124 -0
- ccssh-1.0.0/ccssh/chansock.py +227 -0
- ccssh-1.0.0/ccssh/cli.py +889 -0
- ccssh-1.0.0/ccssh/client.py +1274 -0
- ccssh-1.0.0/ccssh/crypto.py +316 -0
- ccssh-1.0.0/ccssh/dh_groups.py +92 -0
- ccssh-1.0.0/ccssh/forward.py +872 -0
- ccssh-1.0.0/ccssh/gcm.py +87 -0
- ccssh-1.0.0/ccssh/hostcheck.py +217 -0
- ccssh-1.0.0/ccssh/hostkeys.py +250 -0
- ccssh-1.0.0/ccssh/kex.py +222 -0
- ccssh-1.0.0/ccssh/kex_ext.py +429 -0
- ccssh-1.0.0/ccssh/mldsa.py +210 -0
- ccssh-1.0.0/ccssh/mlkem.py +367 -0
- ccssh-1.0.0/ccssh/mux.py +301 -0
- ccssh-1.0.0/ccssh/sftp.py +270 -0
- ccssh-1.0.0/ccssh/shell.py +257 -0
- ccssh-1.0.0/ccssh/sk.py +224 -0
- ccssh-1.0.0/ccssh/sntrup.py +325 -0
- ccssh-1.0.0/ccssh/stub.py +710 -0
- ccssh-1.0.0/ccssh/transport.py +737 -0
- ccssh-1.0.0/ccssh/umac.py +262 -0
- ccssh-1.0.0/ccssh/wire.py +199 -0
- ccssh-1.0.0/ccssh.egg-info/PKG-INFO +442 -0
- ccssh-1.0.0/ccssh.egg-info/SOURCES.txt +52 -0
- ccssh-1.0.0/ccssh.egg-info/dependency_links.txt +1 -0
- ccssh-1.0.0/ccssh.egg-info/entry_points.txt +2 -0
- ccssh-1.0.0/ccssh.egg-info/requires.txt +1 -0
- ccssh-1.0.0/ccssh.egg-info/top_level.txt +1 -0
- ccssh-1.0.0/setup.cfg +4 -0
- ccssh-1.0.0/setup.py +62 -0
- ccssh-1.0.0/tests/__init__.py +0 -0
- ccssh-1.0.0/tests/test_ccssh.py +1732 -0
- ccssh-1.0.0/tests/test_core.py +134 -0
- ccssh-1.0.0/tests/test_fuzz.py +99 -0
- ccssh-1.0.0/tests/test_legacy.py +97 -0
- ccssh-1.0.0/tests/test_live_sshd.py +274 -0
- ccssh-1.0.0/tests/test_mlkem.py +99 -0
- ccssh-1.0.0/tests/test_mux.py +218 -0
- ccssh-1.0.0/tests/test_security.py +1126 -0
- ccssh-1.0.0/tests/test_shell.py +421 -0
- ccssh-1.0.0/tests/test_sk.py +115 -0
- ccssh-1.0.0/tests/test_umac.py +53 -0
ccssh-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 He1Zen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ccssh-1.0.0/MANIFEST.in
ADDED
ccssh-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ccssh
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: From-scratch SSH client in Python (sync + async + CLI), OpenSSH-interop verified
|
|
5
|
+
Author: MishaKorzhik_He1Zen
|
|
6
|
+
Author-email: developer.mishakorzhik@gmail.com
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/mishakorzik/ccssh/issues
|
|
9
|
+
Project-URL: Donate, https://www.buymeacoffee.com/misakorzik
|
|
10
|
+
Keywords: ssh,shell,remote,tcp,forward,tunnel,async,sync,openssh,open,fast,encrypt,encryption,server,network
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.16
|
|
23
|
+
Classifier: Topic :: System :: Networking
|
|
24
|
+
Classifier: Topic :: Security :: Cryptography
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: cryptography>=41
|
|
29
|
+
Dynamic: author
|
|
30
|
+
Dynamic: author-email
|
|
31
|
+
Dynamic: classifier
|
|
32
|
+
Dynamic: description
|
|
33
|
+
Dynamic: description-content-type
|
|
34
|
+
Dynamic: keywords
|
|
35
|
+
Dynamic: license
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
Dynamic: project-url
|
|
38
|
+
Dynamic: requires-dist
|
|
39
|
+
Dynamic: requires-python
|
|
40
|
+
Dynamic: summary
|
|
41
|
+
|
|
42
|
+
# Concurrent-Channels SSH
|
|
43
|
+
|
|
44
|
+
From-scratch SSH-2 client in Python (sync + async + CLI), verified against
|
|
45
|
+
real OpenSSH servers - including post-quantum `mlkem768x25519-sha256` KEX
|
|
46
|
+
and `ssh-mldsa44-ed25519` host/user keys.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install ccssh
|
|
50
|
+
ccssh user@host # interactive shell
|
|
51
|
+
ccssh user@host uptime # one command
|
|
52
|
+
python -m ccssh user@host -N -L 8080:127.0.0.1:80
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
See `tests/test_live_sshd.py` for the live interop proof matrix.
|
|
56
|
+
|
|
57
|
+
## CLI
|
|
58
|
+
|
|
59
|
+
Options work before or after the destination (`ccssh -p 2022 h` and
|
|
60
|
+
`ccssh h -p 2022` are the same); the first bare word is the destination,
|
|
61
|
+
everything after it is the remote command, verbatim.
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
ccssh -4 -i ./id_ed25519 he1zen@host -p 2022 # shell over IPv4 with a key
|
|
65
|
+
ccssh he1zen@host -p 2022 uptime # one command, port after host
|
|
66
|
+
ccssh -t he1zen@host htop # command on a pty
|
|
67
|
+
ccssh -T he1zen@host # shell without pty
|
|
68
|
+
ccssh -N -L 8080:127.0.0.1:80 he1zen@host # local forwarding only
|
|
69
|
+
ccssh -L 8080:127.0.0.1:80 he1zen@host # forward + shell (shared connection)
|
|
70
|
+
ccssh -R 8080:127.0.0.1:80 he1zen@host uptime # forward + one command
|
|
71
|
+
ccssh -N -R 2022:127.0.0.1:22 he1zen@host # remote forwarding only
|
|
72
|
+
ccssh -J jumpuser@jumphost he1zen@target # ProxyJump (comma chains ok)
|
|
73
|
+
ccssh -q he1zen@host 'echo quiet' # quiet: no info output
|
|
74
|
+
ccssh -n he1zen@host < script.sh # stdin from /dev/null
|
|
75
|
+
ccssh -v he1zen@host # verbose: debug1 progress
|
|
76
|
+
ccssh -vv he1zen@host # debug2: full offers + traces
|
|
77
|
+
ccssh -V # version
|
|
78
|
+
ccssh -l he1zen -p 2022 host # explicit login + port
|
|
79
|
+
ccssh --cert ~/.ssh/id_ed25519-cert.pub -i ~/.ssh/id_ed25519 he1zen@host
|
|
80
|
+
ccssh --password-env SSHPASS he1zen@host # password from env (never argv)
|
|
81
|
+
ccssh -o KexAlgorithms=curve25519-sha256 -o Ciphers=aes256-ctr \
|
|
82
|
+
-o MACs=hmac-sha2-512 -o HostKeyAlgorithms=ssh-ed25519 \
|
|
83
|
+
-o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \
|
|
84
|
+
-o UserKnownHostsFile=~/.ssh/known_hosts he1zen@host
|
|
85
|
+
ccssh -c aes256-ctr -m hmac-sha2-512 he1zen@host # like ssh -c/-m
|
|
86
|
+
ccssh -o HostKeyAlgorithms=+ssh-dss he1zen@oldbox # append to the default set
|
|
87
|
+
ccssh -Q cipher # list supported ciphers (mac, kex, key too)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
No password or key given on a terminal? ccssh prompts
|
|
91
|
+
(`he1zen@host's password:`), retrying up to 3 times; encrypted `-i` keys
|
|
92
|
+
prompt for their passphrase. Without a terminal it fails fast instead of
|
|
93
|
+
hanging - use `--password-env` for scripts.
|
|
94
|
+
|
|
95
|
+
## Sync client
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from ccssh import CCSSHClient
|
|
99
|
+
|
|
100
|
+
with CCSSHClient() as c:
|
|
101
|
+
c.connect("host", username="user", password="secret")
|
|
102
|
+
print(c.exec_command("uptime")) # (stdout, stderr, exit_status)
|
|
103
|
+
print(c.exec_command("cat disk.iso", raw_bytes=True)) # bytes, no decoding
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
All `connect()` options:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
c = CCSSHClient(
|
|
110
|
+
timeout=10.0, # socket/connect timeout, seconds
|
|
111
|
+
known_hosts="~/.ssh/known_hosts", # None -> default path
|
|
112
|
+
host_policy="strict", # strict | accept-new | off
|
|
113
|
+
kex=["curve25519-sha256"], # any subset of algs.SUPPORTED_KEX
|
|
114
|
+
ciphers=["aes256-ctr"], # any subset of algs.SUPPORTED_CIPHERS
|
|
115
|
+
macs=["hmac-sha2-512"], # any subset of algs.SUPPORTED_MACS
|
|
116
|
+
hostkeys=["ssh-ed25519"], # any subset of algs.SUPPORTED_HOSTKEYS
|
|
117
|
+
rekey_bytes=4 * 1024**3, # auto-rekey after this much traffic
|
|
118
|
+
rekey_interval=3600.0, # ... or this many seconds
|
|
119
|
+
)
|
|
120
|
+
c.connect("host", port=2222, username="user",
|
|
121
|
+
password="secret", # or key_filename= / cert_filename=
|
|
122
|
+
key_filename="~/.ssh/id_ed25519",
|
|
123
|
+
passphrase="key-passphrase", # for encrypted keys
|
|
124
|
+
cert_filename="~/.ssh/id_ed25519-cert.pub",
|
|
125
|
+
family=0) # 0:any, socket.AF_INET (-4), AF_INET6 (-6)
|
|
126
|
+
print(c.negotiated()) # {"cipher": ..., "mac": ..., "strict": ...}
|
|
127
|
+
c.close()
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Key auth tries each `-i`/`key_filename` in turn, then falls back to the
|
|
131
|
+
password - like OpenSSH. If the password method fails, the same secret
|
|
132
|
+
is tried via keyboard-interactive (RFC 4256, capped rounds/prompts):
|
|
133
|
+
servers like serveo.net that only offer `keyboard-interactive` work with
|
|
134
|
+
no extra flags. Certificates pass through opaquely from the
|
|
135
|
+
`ssh-ed25519-cert-v01@openssh.com` file (user certs) and are verified
|
|
136
|
+
against `@cert-authority` lines in known_hosts (host certs).
|
|
137
|
+
|
|
138
|
+
Host-key policies: `strict` refuses unknown/changed keys (hashed
|
|
139
|
+
known_hosts entries supported), `accept-new` adds unknown hosts,
|
|
140
|
+
`ask` prompts on unknown hosts (tty only, fail-closed otherwise),
|
|
141
|
+
`off` skips verification (tests only). `UserKnownHostsFile` accepts a
|
|
142
|
+
list and the magic name `none`; unknown `-o` keys fail loudly.
|
|
143
|
+
|
|
144
|
+
Exit codes follow ssh: `0` ok, `255` everything else (usage, auth
|
|
145
|
+
`Permission denied`, connect/forward failures), `141` on broken pipe,
|
|
146
|
+
remote exit status passed through for commands.
|
|
147
|
+
|
|
148
|
+
## SFTP (v3)
|
|
149
|
+
|
|
150
|
+
Bytes in, bytes out - encode text yourself:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
s = c.open_sftp()
|
|
154
|
+
s.realpath("/") # canonical path, ends with "/"
|
|
155
|
+
s.mkdir("/sub") # mode= kwarg, default 0o755
|
|
156
|
+
s.put("/sub/f.bin", data) # mode= kwarg, default 0o644
|
|
157
|
+
s.stat("/sub/f.bin") # {"size": ..., "mtime": ..., ...}
|
|
158
|
+
s.listdir("/sub") # ["f.bin"]
|
|
159
|
+
assert s.get("/sub/f.bin") == data
|
|
160
|
+
s.remove("/sub/f.bin")
|
|
161
|
+
s.close() # closes the subsystem channel
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Text files, error handling, big downloads:
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
s.put("/notes.txt", "привіт\n".encode())
|
|
168
|
+
text = s.get("/notes.txt").decode()
|
|
169
|
+
|
|
170
|
+
from ccssh.sftp import SFTPError
|
|
171
|
+
try:
|
|
172
|
+
s.get("/missing")
|
|
173
|
+
except SFTPError as e: # status code + server message, never silent
|
|
174
|
+
print("failed:", e)
|
|
175
|
+
|
|
176
|
+
big = s.get("/disk.iso", max_bytes=4 * 1024**3) # cap a runaway server
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`put` overwrites (`CREAT|TRUNC`) and splits into ~32KB chunks for the
|
|
180
|
+
server window automatically; `get` streams in 32KB reads (a mid-transfer
|
|
181
|
+
server error raises instead of truncating silently).
|
|
182
|
+
|
|
183
|
+
Recipes - `mkdir -p` and a recursive walk:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
def mkdir_p(s, path):
|
|
187
|
+
parts, cur = [p for p in path.split("/") if p], ""
|
|
188
|
+
for p in parts:
|
|
189
|
+
cur += "/" + p
|
|
190
|
+
try:
|
|
191
|
+
s.mkdir(cur)
|
|
192
|
+
except SFTPError: # exists (or no permission - then the put fails next)
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
def walk(s, path):
|
|
196
|
+
"""Yield (dirpath, filenames) like os.walk."""
|
|
197
|
+
yield path, s.listdir(path)
|
|
198
|
+
|
|
199
|
+
mkdir_p(s, "/a/b/c")
|
|
200
|
+
s.put("/a/b/c/f.txt", b"x")
|
|
201
|
+
for dirpath, names in walk(s, "/a"):
|
|
202
|
+
print(dirpath, names)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Interactive shell
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
from ccssh import shell as SH
|
|
209
|
+
|
|
210
|
+
code = SH.run_interactive(c) # ssh-like shell, returns exit status
|
|
211
|
+
code = SH.run_interactive(c, pty=False) # ssh -T: no pty
|
|
212
|
+
code = SH.run_interactive(c, command="htop") # ssh -t: pty exec
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Ctrl+D exits (the remote shell sees EOF and closes; no hanging, no
|
|
216
|
+
Ctrl+C needed). Idle sessions stay up: steady-state read timeouts send
|
|
217
|
+
SSH IGNORE keepalives instead of dropping (like OpenSSH); handshake and
|
|
218
|
+
SFTP window waits still fail fast on a dead peer. Terminal goes to raw
|
|
219
|
+
and is always restored. Programmatic use without a terminal:
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
sess = c.open_shell(cols=80, rows=24) # pty + shell
|
|
223
|
+
sess = c.open_shell(pty=False) # shell without pty
|
|
224
|
+
sess = c.open_shell(command="top") # pty exec channel
|
|
225
|
+
sess.send(b"echo hi\n") # stdin bytes (window-aware)
|
|
226
|
+
sess.send_eof() # half-close stdin
|
|
227
|
+
sess.window_change(132, 50) # terminal resize
|
|
228
|
+
sess.close() # send CLOSE, returns last exit status or None
|
|
229
|
+
print(sess.exit_status, sess.eof_received, sess.no_pty)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Read server output from `sess._client._t.recv_packet()` and parse
|
|
233
|
+
`CHANNEL_DATA` / `CHANNEL_EXTENDED_DATA` / `exit-status` / `EOF` / `CLOSE`
|
|
234
|
+
(see `tests/test_shell.py` for a full loop). Exactly one thread may
|
|
235
|
+
receive per connection.
|
|
236
|
+
|
|
237
|
+
## Local forwarding (`-L`)
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
fwd = c.forward_local(8080, "internal.db", 5432) # -L 8080:internal.db:5432
|
|
241
|
+
print(fwd.port) # actual port (0 means auto-assign)
|
|
242
|
+
import socket
|
|
243
|
+
s = socket.create_connection(("127.0.0.1", fwd.port))
|
|
244
|
+
s.sendall(b"ping")
|
|
245
|
+
print(s.recv(4))
|
|
246
|
+
print(c.exec_command("uptime")) # exec works on the same connection (mux)
|
|
247
|
+
fwd.close()
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Exec/sftp/shell share one connection with forwarding through the
|
|
251
|
+
channel multiplexer (`ccssh/mux.py`): a single reader thread owns the
|
|
252
|
+
transport and routes packets to per-channel queues, so concurrent
|
|
253
|
+
channels never interleave the framing. Rekey still needs idle channels
|
|
254
|
+
(close channels and forwarders first); auto-rekey is deferred while the
|
|
255
|
+
mux is busy.
|
|
256
|
+
|
|
257
|
+
## Remote forwarding (`-R`) and ProxyJump (`-J`)
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
rfwd = c.forward_remote(0, "127.0.0.1", 22) # server listens (0 = auto)
|
|
261
|
+
print(rfwd.port) # real server-side port, reported back
|
|
262
|
+
rfwd.close() # also sends cancel-tcpip-forward
|
|
263
|
+
|
|
264
|
+
jump = CCSSHClient()
|
|
265
|
+
jump.connect("jumphost", username="juser", key_filename="~/.ssh/id_ed25519")
|
|
266
|
+
tun = jump.open_channel_socket("target", 22) # direct-tcpip, socket-shaped
|
|
267
|
+
c.connect("target", username="user", password="pw", sock=tun)
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`-L` and `-R` cannot share one connection (one forwarder per
|
|
271
|
+
connection); either one shares it with exec/sftp/shell freely.
|
|
272
|
+
|
|
273
|
+
## Rekey
|
|
274
|
+
|
|
275
|
+
```python
|
|
276
|
+
out, _, _ = c.exec_command("one")
|
|
277
|
+
c.rekey() # new KEX, session_id kept, sequence numbers reset
|
|
278
|
+
out, _, _ = c.exec_command("two")
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Call only with no open channels (between operations, forwarders
|
|
282
|
+
closed). Rekey also happens automatically per `rekey_bytes` /
|
|
283
|
+
`rekey_interval` (deferred while channels are open).
|
|
284
|
+
|
|
285
|
+
## Algorithms
|
|
286
|
+
|
|
287
|
+
```python
|
|
288
|
+
from ccssh import algs
|
|
289
|
+
print(algs.coverage())
|
|
290
|
+
# kex 14/16, ciphers 6/10, MACs 6/16 + certs - all live-verified vs OpenSSH
|
|
291
|
+
print(algs.SUPPORTED_KEX) # incl. "mlkem768x25519-sha256"
|
|
292
|
+
print(algs.SUPPORTED_CIPHERS) # aes*-ctr, aes*gcm, chacha20-poly1305
|
|
293
|
+
print(algs.SUPPORTED_MACS) # hmac-sha2-*, hmac-sha1 (+-etm)
|
|
294
|
+
print(algs.SUPPORTED_HOSTKEYS) # incl. "ssh-mldsa44-ed25519"
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
The strict top tier (`algs.MAX_KEX`, `MAX_CIPHERS`, `MAX_MACS`,
|
|
298
|
+
`MAX_HOSTKEYS`: PQ hybrids, 256-bit ciphers/MACs only) can be passed
|
|
299
|
+
explicitly, e.g. `CCSSHClient(ciphers=algs.MAX_CIPHERS)`. Negotiation
|
|
300
|
+
still intersects with the server, so it fails closed when the server
|
|
301
|
+
lacks top-tier algorithms instead of silently downgrading.
|
|
302
|
+
|
|
303
|
+
Legacy algorithms (`algs.LEGACY_KEX`, `LEGACY_CIPHERS`, `LEGACY_MACS`,
|
|
304
|
+
`LEGACY_HOSTKEYS`: CBC ciphers, 3DES, MD5/SHA1-96/UMAC MACs, group1-sha1,
|
|
305
|
+
gex-sha1, ssh-rsa host keys) are implemented and live-tested but never
|
|
306
|
+
offered by default - name them explicitly to interoperate with old
|
|
307
|
+
servers. The server side needs them enabled too:
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
# sshd_config on the old box
|
|
311
|
+
Ciphers +aes256-cbc,3des-cbc
|
|
312
|
+
MACs +hmac-md5,umac-64@openssh.com
|
|
313
|
+
KexAlgorithms +diffie-hellman-group1-sha1
|
|
314
|
+
HostKeyAlgorithms +ssh-rsa
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
ccssh -o Ciphers=aes256-cbc -o MACs=hmac-sha2-256 he1zen@oldbox
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Legacy tier: `ssh-dss` (1024-bit DSA/SHA-1, removed from OpenSSH 10.x) is
|
|
322
|
+
implemented like every other legacy primitive - never offered by default,
|
|
323
|
+
only when named explicitly (e.g. `-o HostKeyAlgorithms=ssh-dss`), and the
|
|
324
|
+
server side needs it enabled too.
|
|
325
|
+
|
|
326
|
+
## Certificates
|
|
327
|
+
|
|
328
|
+
```python
|
|
329
|
+
import base64
|
|
330
|
+
from ccssh import certs as CERT
|
|
331
|
+
|
|
332
|
+
line = open("host-cert.pub").read().split() # "ssh-ed25519-cert-v01@openssh.com AAAA..."
|
|
333
|
+
info = CERT.parse_cert(base64.b64decode(line[1]))
|
|
334
|
+
print(info["principals"], info["role"]) # role: CERT.CERT_TYPE_HOST / CERT_TYPE_USER
|
|
335
|
+
|
|
336
|
+
CERT.verify_cert(base64.b64decode(line[1]), expected_principal="db.internal",
|
|
337
|
+
is_host=True, ca_blobs=[ca_public_blob]) # raises CERT.CertError if bad
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
In practice you rarely call this: user certs go through
|
|
341
|
+
`connect(..., cert_filename=...)`, host certs are verified automatically
|
|
342
|
+
from `@cert-authority` lines in known_hosts. To mint test certs:
|
|
343
|
+
|
|
344
|
+
```python
|
|
345
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
346
|
+
from ccssh import wire as W
|
|
347
|
+
|
|
348
|
+
ca = Ed25519PrivateKey.generate()
|
|
349
|
+
host = Ed25519PrivateKey.generate()
|
|
350
|
+
pub_fields = W.enc_string(host.public_key().public_bytes_raw())
|
|
351
|
+
blob = CERT.build_cert(
|
|
352
|
+
keytype="ssh-ed25519-cert-v01@openssh.com", pub_fields=pub_fields,
|
|
353
|
+
role=CERT.CERT_TYPE_HOST, principals="db.internal", ca_priv=ca)
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
## ML-DSA (`ssh-mldsa44-ed25519`)
|
|
357
|
+
|
|
358
|
+
Needs a `cryptography` build with ML-DSA (else `available()` is False
|
|
359
|
+
and these raise):
|
|
360
|
+
|
|
361
|
+
```python
|
|
362
|
+
from ccssh import mldsa as ML
|
|
363
|
+
print(ML.available())
|
|
364
|
+
|
|
365
|
+
kp = ML.KeyPair.generate() # or from_seeds(mldsa32, ed32)
|
|
366
|
+
open("id_mldsa.pub", "w").write(kp.public_line("comment") + "\n")
|
|
367
|
+
ML.save_openssh_private("id_mldsa", kp, "comment")
|
|
368
|
+
kp2 = ML.load_openssh_private("id_mldsa") # passphrase=b"..." if encrypted
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
The files are standard `openssh-key-v1` / one-line `.pub`, so
|
|
372
|
+
`ssh-keygen` (10.6+) and `sshd` accept them, and ccssh uses them as
|
|
373
|
+
`-i` keys and host keys directly.
|
|
374
|
+
|
|
375
|
+
## Post-quantum sntrup761 (`sntrup761x25519-sha512`)
|
|
376
|
+
|
|
377
|
+
Needs liboqs installed where the loader finds it (else `max`/defaults
|
|
378
|
+
skip it and forcing it raises `BackendMissing` with install hints):
|
|
379
|
+
|
|
380
|
+
```python
|
|
381
|
+
from ccssh import sntrup as SN
|
|
382
|
+
be = SN.load_backend() # LibOQSBackend via ctypes, no Python deps
|
|
383
|
+
ek, sk = be.generate() # ek 1158 B
|
|
384
|
+
ct, ss1 = be.encap(ek) # ct 1039 B, ss 32 B
|
|
385
|
+
assert be.decap(sk, ct) == ss1
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
```bash
|
|
389
|
+
ccssh -o KexAlgorithms=sntrup761x25519-sha512 he1zen@host
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
## FIDO2 security keys (`sk-ssh-ed25519`, `sk-ecdsa-sha2-nistp256`)
|
|
393
|
+
|
|
394
|
+
Parsing, verification and userauth assembly follow OpenSSH
|
|
395
|
+
PROTOCOL.u2f; only the hardware touch itself needs a token. The touch
|
|
396
|
+
is an injectable callback, so any middleware fits:
|
|
397
|
+
|
|
398
|
+
```python
|
|
399
|
+
from ccssh import sk as SK
|
|
400
|
+
|
|
401
|
+
key = SK.load_openssh_private("~/.ssh/id_sk") # needs key_handle inside
|
|
402
|
+
key.signer = lambda app, handle, msg: my_token_sign(app, handle, msg)
|
|
403
|
+
c.connect("host", username="u", key_filename="~/.ssh/id_sk") # uses it
|
|
404
|
+
|
|
405
|
+
SK.verify_nested(key.public_blob(), message, sig) # True/False, never raises
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Without a signer, signing raises a clear error (never a silent server
|
|
409
|
+
rejection). `-i sk_file` works out of the box; login itself needs the
|
|
410
|
+
token attached, so there is no live test - only spec-level tests plus
|
|
411
|
+
`ssh-keygen -L` cross-checks of our blobs.
|
|
412
|
+
|
|
413
|
+
## Async client
|
|
414
|
+
|
|
415
|
+
```python
|
|
416
|
+
import asyncio
|
|
417
|
+
from ccssh import AsyncCCSSHClient
|
|
418
|
+
|
|
419
|
+
async def go():
|
|
420
|
+
c = AsyncCCSSHClient(host_policy="off") # same knobs as sync (no rekey timers yet)
|
|
421
|
+
await c.connect("host", username="u", password="pw", family=0)
|
|
422
|
+
out, err, code = await c.exec_command("echo hi")
|
|
423
|
+
await c.rekey()
|
|
424
|
+
await c.close()
|
|
425
|
+
return out, code
|
|
426
|
+
|
|
427
|
+
print(asyncio.run(go()))
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
## Errors
|
|
431
|
+
|
|
432
|
+
```python
|
|
433
|
+
from ccssh import CCSSHClient, AuthError, HostKeyError, CCSSHError
|
|
434
|
+
try:
|
|
435
|
+
...
|
|
436
|
+
except AuthError: # bad password / rejected key
|
|
437
|
+
...
|
|
438
|
+
except HostKeyError: # unknown or changed host key (strict policy)
|
|
439
|
+
...
|
|
440
|
+
except CCSSHError: # protocol/transport failures (base of the above two)
|
|
441
|
+
...
|
|
442
|
+
```
|