ccssh 1.0.0__tar.gz → 1.0.1__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/ccssh.egg-info → ccssh-1.0.1}/PKG-INFO +61 -15
- {ccssh-1.0.0 → ccssh-1.0.1}/README.md +59 -13
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/__init__.py +1 -1
- ccssh-1.0.1/ccssh/agent.py +223 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/algs.py +71 -4
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/async_client.py +102 -14
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/channel.py +20 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/cli.py +185 -31
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/client.py +295 -40
- ccssh-1.0.1/ccssh/compress.py +109 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/forward.py +591 -3
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/kex.py +1 -1
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/mux.py +55 -4
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/shell.py +9 -4
- ccssh-1.0.1/ccssh/socks.py +144 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/stub.py +154 -11
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/transport.py +208 -12
- ccssh-1.0.1/ccssh/x11.py +148 -0
- {ccssh-1.0.0 → ccssh-1.0.1/ccssh.egg-info}/PKG-INFO +61 -15
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh.egg-info/SOURCES.txt +6 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/setup.py +2 -2
- ccssh-1.0.1/tests/test_compress.py +366 -0
- ccssh-1.0.1/tests/test_forward_extra.py +866 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_security.py +2 -6
- {ccssh-1.0.0 → ccssh-1.0.1}/LICENSE +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/MANIFEST.in +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/__main__.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/auth.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/certs.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/chacha.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/chansock.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/crypto.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/dh_groups.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/gcm.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/hostcheck.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/hostkeys.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/kex_ext.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/mldsa.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/mlkem.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/sftp.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/sk.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/sntrup.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/umac.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/wire.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh.egg-info/dependency_links.txt +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh.egg-info/entry_points.txt +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh.egg-info/requires.txt +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/ccssh.egg-info/top_level.txt +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/setup.cfg +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/tests/__init__.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_ccssh.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_core.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_fuzz.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_legacy.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_live_sshd.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_mlkem.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_mux.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_shell.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_sk.py +0 -0
- {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_umac.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ccssh
|
|
3
|
-
Version: 1.0.
|
|
4
|
-
Summary: From-scratch SSH client in Python
|
|
3
|
+
Version: 1.0.1
|
|
4
|
+
Summary: From-scratch SSH client in Python, OpenSSH-interop verified
|
|
5
5
|
Author: MishaKorzhik_He1Zen
|
|
6
6
|
Author-email: developer.mishakorzhik@gmail.com
|
|
7
7
|
License: MIT
|
|
@@ -69,6 +69,10 @@ ccssh -N -L 8080:127.0.0.1:80 he1zen@host # local forwarding only
|
|
|
69
69
|
ccssh -L 8080:127.0.0.1:80 he1zen@host # forward + shell (shared connection)
|
|
70
70
|
ccssh -R 8080:127.0.0.1:80 he1zen@host uptime # forward + one command
|
|
71
71
|
ccssh -N -R 2022:127.0.0.1:22 he1zen@host # remote forwarding only
|
|
72
|
+
ccssh -N -D 1080 he1zen@host # dynamic forward: SOCKS4/5 proxy on 1080
|
|
73
|
+
ccssh -A he1zen@host # forward the local ssh-agent
|
|
74
|
+
ccssh -X he1zen@host xterm # forward the local X display (untrusted)
|
|
75
|
+
ccssh -Y he1zen@host xterm # ... trusted (like ssh -Y)
|
|
72
76
|
ccssh -J jumpuser@jumphost he1zen@target # ProxyJump (comma chains ok)
|
|
73
77
|
ccssh -q he1zen@host 'echo quiet' # quiet: no info output
|
|
74
78
|
ccssh -n he1zen@host < script.sh # stdin from /dev/null
|
|
@@ -84,7 +88,10 @@ ccssh -o KexAlgorithms=curve25519-sha256 -o Ciphers=aes256-ctr \
|
|
|
84
88
|
-o UserKnownHostsFile=~/.ssh/known_hosts he1zen@host
|
|
85
89
|
ccssh -c aes256-ctr -m hmac-sha2-512 he1zen@host # like ssh -c/-m
|
|
86
90
|
ccssh -o HostKeyAlgorithms=+ssh-dss he1zen@oldbox # append to the default set
|
|
87
|
-
ccssh -
|
|
91
|
+
ccssh -C he1zen@host uptime # like ssh -C: zlib@openssh.com
|
|
92
|
+
ccssh -o Compression=yes -o CompressionLevel=9 he1zen@host # levels 1..9, default 6
|
|
93
|
+
ccssh -Q cipher # list supported ciphers (mac, kex, key, compression too)
|
|
94
|
+
ccssh -Q compression # none, zlib@openssh.com (like ssh -Q)
|
|
88
95
|
```
|
|
89
96
|
|
|
90
97
|
No password or key given on a terminal? ccssh prompts
|
|
@@ -114,6 +121,10 @@ c = CCSSHClient(
|
|
|
114
121
|
ciphers=["aes256-ctr"], # any subset of algs.SUPPORTED_CIPHERS
|
|
115
122
|
macs=["hmac-sha2-512"], # any subset of algs.SUPPORTED_MACS
|
|
116
123
|
hostkeys=["ssh-ed25519"], # any subset of algs.SUPPORTED_HOSTKEYS
|
|
124
|
+
compression=True, # like ssh -C (zlib@openssh.com); False by default
|
|
125
|
+
compression_level=6, # 1..9, like CompressionLevel
|
|
126
|
+
use_agent=True, # publickey via $SSH_AUTH_SOCK (RFC 9987)
|
|
127
|
+
agent_path=None, # ... or an explicit agent socket path
|
|
117
128
|
rekey_bytes=4 * 1024**3, # auto-rekey after this much traffic
|
|
118
129
|
rekey_interval=3600.0, # ... or this many seconds
|
|
119
130
|
)
|
|
@@ -123,13 +134,24 @@ c.connect("host", port=2222, username="user",
|
|
|
123
134
|
passphrase="key-passphrase", # for encrypted keys
|
|
124
135
|
cert_filename="~/.ssh/id_ed25519-cert.pub",
|
|
125
136
|
family=0) # 0:any, socket.AF_INET (-4), AF_INET6 (-6)
|
|
126
|
-
print(c.negotiated()) # {"cipher": ..., "mac": ..., "
|
|
137
|
+
print(c.negotiated()) # {"kex": ..., "cipher": ..., "mac": ..., "compression": ..., ...}
|
|
127
138
|
c.close()
|
|
128
139
|
```
|
|
129
140
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
141
|
+
Compression is 1:1 with OpenSSH: `zlib@openssh.com` (delayed, starts after
|
|
142
|
+
auth) with `-C` / `Compression=yes` / `compression=True`, levels 1..9
|
|
143
|
+
(`CompressionLevel`, default 6), `none` by default with the OpenSSH proposal
|
|
144
|
+
`none,zlib@openssh.com`. Legacy `zlib` (immediate, pre-auth) interoperates
|
|
145
|
+
with old servers when named explicitly (`compression=["zlib", "none"]`) but
|
|
146
|
+
is never offered by default - like every other legacy primitive. Rekey keeps
|
|
147
|
+
compression enabled with fresh dictionaries (RFC 4253).
|
|
148
|
+
|
|
149
|
+
Key auth tries each `-i`/`key_filename` in turn, then agent keys from
|
|
150
|
+
`$SSH_AUTH_SOCK` (RFC 9987: list + sign, private material never leaves the
|
|
151
|
+
agent; disable with `use_agent=False`, `-o IdentityAgent=path/none` or
|
|
152
|
+
`-o IdentitiesOnly=yes`), then falls back to the password - like OpenSSH.
|
|
153
|
+
If the password method fails, the same secret is tried via
|
|
154
|
+
keyboard-interactive (RFC 4256, capped rounds/prompts):
|
|
133
155
|
servers like serveo.net that only offer `keyboard-interactive` work with
|
|
134
156
|
no extra flags. Certificates pass through opaquely from the
|
|
135
157
|
`ssh-ed25519-cert-v01@openssh.com` file (user certs) and are verified
|
|
@@ -267,8 +289,31 @@ tun = jump.open_channel_socket("target", 22) # direct-tcpip, socket-shaped
|
|
|
267
289
|
c.connect("target", username="user", password="pw", sock=tun)
|
|
268
290
|
```
|
|
269
291
|
|
|
270
|
-
`-L` and `-
|
|
271
|
-
connection); either one shares it with exec/sftp/shell
|
|
292
|
+
`-L`, `-R` and `-D` cannot share one connection (one port forwarder per
|
|
293
|
+
connection); either one shares it with exec/sftp/shell - and with agent
|
|
294
|
+
(`-A`) and X11 (`-X`/`-Y`) forwarding - freely.
|
|
295
|
+
|
|
296
|
+
## Dynamic forwarding (`-D`), agent (`-A`) and X11 (`-X`/`-Y`)
|
|
297
|
+
|
|
298
|
+
```python
|
|
299
|
+
dfwd = c.forward_dynamic(1080) # SOCKS4/4a/5 proxy on 127.0.0.1:1080
|
|
300
|
+
# point a browser at socks5://127.0.0.1:1080; each CONNECT becomes one
|
|
301
|
+
# direct-tcpip channel through the server (TCP only, like ssh -D)
|
|
302
|
+
dfwd.close()
|
|
303
|
+
|
|
304
|
+
c.forward_agent() # ssh -A: relay auth-agent@openssh.com to $SSH_AUTH_SOCK
|
|
305
|
+
print(c.exec_command("ssh-add -l", forward_agent=True)[0]) # remote sees local keys
|
|
306
|
+
# refused auth-agent-req (AllowAgentForwarding no) only warns: the session
|
|
307
|
+
# continues without forwarding, like a refused pty
|
|
308
|
+
|
|
309
|
+
c.forward_x11() # ssh -X: relay x11 channels to the local $DISPLAY
|
|
310
|
+
print(c.exec_command("echo $DISPLAY", x11=True)[0]) # server-assigned display
|
|
311
|
+
# x11=True is untrusted (-X), x11_trusted=True is -Y; a refused x11-req or a
|
|
312
|
+
# missing local DISPLAY only warns (fake cookie, per-connection refusal)
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
`-o ForwardAgent=yes/no`, `-o ForwardX11=yes/no`,
|
|
316
|
+
`-o ForwardX11Trusted=yes/no` mirror the flags.
|
|
272
317
|
|
|
273
318
|
## Rekey
|
|
274
319
|
|
|
@@ -287,11 +332,12 @@ closed). Rekey also happens automatically per `rekey_bytes` /
|
|
|
287
332
|
```python
|
|
288
333
|
from ccssh import algs
|
|
289
334
|
print(algs.coverage())
|
|
290
|
-
# kex 14/16, ciphers 6/10, MACs 6/16 + certs - all live-verified vs OpenSSH
|
|
335
|
+
# kex 14/16, ciphers 6/10, MACs 6/16, compression 2/2 + certs - all live-verified vs OpenSSH
|
|
291
336
|
print(algs.SUPPORTED_KEX) # incl. "mlkem768x25519-sha256"
|
|
292
337
|
print(algs.SUPPORTED_CIPHERS) # aes*-ctr, aes*gcm, chacha20-poly1305
|
|
293
338
|
print(algs.SUPPORTED_MACS) # hmac-sha2-*, hmac-sha1 (+-etm)
|
|
294
339
|
print(algs.SUPPORTED_HOSTKEYS) # incl. "ssh-mldsa44-ed25519"
|
|
340
|
+
print(algs.SUPPORTED_COMPRESSION) # none, zlib@openssh.com (like ssh -Q)
|
|
295
341
|
```
|
|
296
342
|
|
|
297
343
|
The strict top tier (`algs.MAX_KEX`, `MAX_CIPHERS`, `MAX_MACS`,
|
|
@@ -301,10 +347,10 @@ still intersects with the server, so it fails closed when the server
|
|
|
301
347
|
lacks top-tier algorithms instead of silently downgrading.
|
|
302
348
|
|
|
303
349
|
Legacy algorithms (`algs.LEGACY_KEX`, `LEGACY_CIPHERS`, `LEGACY_MACS`,
|
|
304
|
-
`LEGACY_HOSTKEYS`: CBC ciphers, 3DES, MD5/SHA1-96/UMAC
|
|
305
|
-
gex-sha1, ssh-rsa host keys) are implemented
|
|
306
|
-
offered by default - name them explicitly to
|
|
307
|
-
servers. The server side needs them enabled too:
|
|
350
|
+
`LEGACY_HOSTKEYS`, `LEGACY_COMPRESSION`: CBC ciphers, 3DES, MD5/SHA1-96/UMAC
|
|
351
|
+
MACs, group1-sha1, gex-sha1, ssh-rsa host keys, plain `zlib`) are implemented
|
|
352
|
+
and live-tested but never offered by default - name them explicitly to
|
|
353
|
+
interoperate with old servers. The server side needs them enabled too:
|
|
308
354
|
|
|
309
355
|
```bash
|
|
310
356
|
# sshd_config on the old box
|
|
@@ -417,7 +463,7 @@ import asyncio
|
|
|
417
463
|
from ccssh import AsyncCCSSHClient
|
|
418
464
|
|
|
419
465
|
async def go():
|
|
420
|
-
c = AsyncCCSSHClient(host_policy="off") # same knobs as sync (
|
|
466
|
+
c = AsyncCCSSHClient(host_policy="off") # same knobs as sync (incl. compression/compression_level)
|
|
421
467
|
await c.connect("host", username="u", password="pw", family=0)
|
|
422
468
|
out, err, code = await c.exec_command("echo hi")
|
|
423
469
|
await c.rekey()
|
|
@@ -28,6 +28,10 @@ ccssh -N -L 8080:127.0.0.1:80 he1zen@host # local forwarding only
|
|
|
28
28
|
ccssh -L 8080:127.0.0.1:80 he1zen@host # forward + shell (shared connection)
|
|
29
29
|
ccssh -R 8080:127.0.0.1:80 he1zen@host uptime # forward + one command
|
|
30
30
|
ccssh -N -R 2022:127.0.0.1:22 he1zen@host # remote forwarding only
|
|
31
|
+
ccssh -N -D 1080 he1zen@host # dynamic forward: SOCKS4/5 proxy on 1080
|
|
32
|
+
ccssh -A he1zen@host # forward the local ssh-agent
|
|
33
|
+
ccssh -X he1zen@host xterm # forward the local X display (untrusted)
|
|
34
|
+
ccssh -Y he1zen@host xterm # ... trusted (like ssh -Y)
|
|
31
35
|
ccssh -J jumpuser@jumphost he1zen@target # ProxyJump (comma chains ok)
|
|
32
36
|
ccssh -q he1zen@host 'echo quiet' # quiet: no info output
|
|
33
37
|
ccssh -n he1zen@host < script.sh # stdin from /dev/null
|
|
@@ -43,7 +47,10 @@ ccssh -o KexAlgorithms=curve25519-sha256 -o Ciphers=aes256-ctr \
|
|
|
43
47
|
-o UserKnownHostsFile=~/.ssh/known_hosts he1zen@host
|
|
44
48
|
ccssh -c aes256-ctr -m hmac-sha2-512 he1zen@host # like ssh -c/-m
|
|
45
49
|
ccssh -o HostKeyAlgorithms=+ssh-dss he1zen@oldbox # append to the default set
|
|
46
|
-
ccssh -
|
|
50
|
+
ccssh -C he1zen@host uptime # like ssh -C: zlib@openssh.com
|
|
51
|
+
ccssh -o Compression=yes -o CompressionLevel=9 he1zen@host # levels 1..9, default 6
|
|
52
|
+
ccssh -Q cipher # list supported ciphers (mac, kex, key, compression too)
|
|
53
|
+
ccssh -Q compression # none, zlib@openssh.com (like ssh -Q)
|
|
47
54
|
```
|
|
48
55
|
|
|
49
56
|
No password or key given on a terminal? ccssh prompts
|
|
@@ -73,6 +80,10 @@ c = CCSSHClient(
|
|
|
73
80
|
ciphers=["aes256-ctr"], # any subset of algs.SUPPORTED_CIPHERS
|
|
74
81
|
macs=["hmac-sha2-512"], # any subset of algs.SUPPORTED_MACS
|
|
75
82
|
hostkeys=["ssh-ed25519"], # any subset of algs.SUPPORTED_HOSTKEYS
|
|
83
|
+
compression=True, # like ssh -C (zlib@openssh.com); False by default
|
|
84
|
+
compression_level=6, # 1..9, like CompressionLevel
|
|
85
|
+
use_agent=True, # publickey via $SSH_AUTH_SOCK (RFC 9987)
|
|
86
|
+
agent_path=None, # ... or an explicit agent socket path
|
|
76
87
|
rekey_bytes=4 * 1024**3, # auto-rekey after this much traffic
|
|
77
88
|
rekey_interval=3600.0, # ... or this many seconds
|
|
78
89
|
)
|
|
@@ -82,13 +93,24 @@ c.connect("host", port=2222, username="user",
|
|
|
82
93
|
passphrase="key-passphrase", # for encrypted keys
|
|
83
94
|
cert_filename="~/.ssh/id_ed25519-cert.pub",
|
|
84
95
|
family=0) # 0:any, socket.AF_INET (-4), AF_INET6 (-6)
|
|
85
|
-
print(c.negotiated()) # {"cipher": ..., "mac": ..., "
|
|
96
|
+
print(c.negotiated()) # {"kex": ..., "cipher": ..., "mac": ..., "compression": ..., ...}
|
|
86
97
|
c.close()
|
|
87
98
|
```
|
|
88
99
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
100
|
+
Compression is 1:1 with OpenSSH: `zlib@openssh.com` (delayed, starts after
|
|
101
|
+
auth) with `-C` / `Compression=yes` / `compression=True`, levels 1..9
|
|
102
|
+
(`CompressionLevel`, default 6), `none` by default with the OpenSSH proposal
|
|
103
|
+
`none,zlib@openssh.com`. Legacy `zlib` (immediate, pre-auth) interoperates
|
|
104
|
+
with old servers when named explicitly (`compression=["zlib", "none"]`) but
|
|
105
|
+
is never offered by default - like every other legacy primitive. Rekey keeps
|
|
106
|
+
compression enabled with fresh dictionaries (RFC 4253).
|
|
107
|
+
|
|
108
|
+
Key auth tries each `-i`/`key_filename` in turn, then agent keys from
|
|
109
|
+
`$SSH_AUTH_SOCK` (RFC 9987: list + sign, private material never leaves the
|
|
110
|
+
agent; disable with `use_agent=False`, `-o IdentityAgent=path/none` or
|
|
111
|
+
`-o IdentitiesOnly=yes`), then falls back to the password - like OpenSSH.
|
|
112
|
+
If the password method fails, the same secret is tried via
|
|
113
|
+
keyboard-interactive (RFC 4256, capped rounds/prompts):
|
|
92
114
|
servers like serveo.net that only offer `keyboard-interactive` work with
|
|
93
115
|
no extra flags. Certificates pass through opaquely from the
|
|
94
116
|
`ssh-ed25519-cert-v01@openssh.com` file (user certs) and are verified
|
|
@@ -226,8 +248,31 @@ tun = jump.open_channel_socket("target", 22) # direct-tcpip, socket-shaped
|
|
|
226
248
|
c.connect("target", username="user", password="pw", sock=tun)
|
|
227
249
|
```
|
|
228
250
|
|
|
229
|
-
`-L` and `-
|
|
230
|
-
connection); either one shares it with exec/sftp/shell
|
|
251
|
+
`-L`, `-R` and `-D` cannot share one connection (one port forwarder per
|
|
252
|
+
connection); either one shares it with exec/sftp/shell - and with agent
|
|
253
|
+
(`-A`) and X11 (`-X`/`-Y`) forwarding - freely.
|
|
254
|
+
|
|
255
|
+
## Dynamic forwarding (`-D`), agent (`-A`) and X11 (`-X`/`-Y`)
|
|
256
|
+
|
|
257
|
+
```python
|
|
258
|
+
dfwd = c.forward_dynamic(1080) # SOCKS4/4a/5 proxy on 127.0.0.1:1080
|
|
259
|
+
# point a browser at socks5://127.0.0.1:1080; each CONNECT becomes one
|
|
260
|
+
# direct-tcpip channel through the server (TCP only, like ssh -D)
|
|
261
|
+
dfwd.close()
|
|
262
|
+
|
|
263
|
+
c.forward_agent() # ssh -A: relay auth-agent@openssh.com to $SSH_AUTH_SOCK
|
|
264
|
+
print(c.exec_command("ssh-add -l", forward_agent=True)[0]) # remote sees local keys
|
|
265
|
+
# refused auth-agent-req (AllowAgentForwarding no) only warns: the session
|
|
266
|
+
# continues without forwarding, like a refused pty
|
|
267
|
+
|
|
268
|
+
c.forward_x11() # ssh -X: relay x11 channels to the local $DISPLAY
|
|
269
|
+
print(c.exec_command("echo $DISPLAY", x11=True)[0]) # server-assigned display
|
|
270
|
+
# x11=True is untrusted (-X), x11_trusted=True is -Y; a refused x11-req or a
|
|
271
|
+
# missing local DISPLAY only warns (fake cookie, per-connection refusal)
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
`-o ForwardAgent=yes/no`, `-o ForwardX11=yes/no`,
|
|
275
|
+
`-o ForwardX11Trusted=yes/no` mirror the flags.
|
|
231
276
|
|
|
232
277
|
## Rekey
|
|
233
278
|
|
|
@@ -246,11 +291,12 @@ closed). Rekey also happens automatically per `rekey_bytes` /
|
|
|
246
291
|
```python
|
|
247
292
|
from ccssh import algs
|
|
248
293
|
print(algs.coverage())
|
|
249
|
-
# kex 14/16, ciphers 6/10, MACs 6/16 + certs - all live-verified vs OpenSSH
|
|
294
|
+
# kex 14/16, ciphers 6/10, MACs 6/16, compression 2/2 + certs - all live-verified vs OpenSSH
|
|
250
295
|
print(algs.SUPPORTED_KEX) # incl. "mlkem768x25519-sha256"
|
|
251
296
|
print(algs.SUPPORTED_CIPHERS) # aes*-ctr, aes*gcm, chacha20-poly1305
|
|
252
297
|
print(algs.SUPPORTED_MACS) # hmac-sha2-*, hmac-sha1 (+-etm)
|
|
253
298
|
print(algs.SUPPORTED_HOSTKEYS) # incl. "ssh-mldsa44-ed25519"
|
|
299
|
+
print(algs.SUPPORTED_COMPRESSION) # none, zlib@openssh.com (like ssh -Q)
|
|
254
300
|
```
|
|
255
301
|
|
|
256
302
|
The strict top tier (`algs.MAX_KEX`, `MAX_CIPHERS`, `MAX_MACS`,
|
|
@@ -260,10 +306,10 @@ still intersects with the server, so it fails closed when the server
|
|
|
260
306
|
lacks top-tier algorithms instead of silently downgrading.
|
|
261
307
|
|
|
262
308
|
Legacy algorithms (`algs.LEGACY_KEX`, `LEGACY_CIPHERS`, `LEGACY_MACS`,
|
|
263
|
-
`LEGACY_HOSTKEYS`: CBC ciphers, 3DES, MD5/SHA1-96/UMAC
|
|
264
|
-
gex-sha1, ssh-rsa host keys) are implemented
|
|
265
|
-
offered by default - name them explicitly to
|
|
266
|
-
servers. The server side needs them enabled too:
|
|
309
|
+
`LEGACY_HOSTKEYS`, `LEGACY_COMPRESSION`: CBC ciphers, 3DES, MD5/SHA1-96/UMAC
|
|
310
|
+
MACs, group1-sha1, gex-sha1, ssh-rsa host keys, plain `zlib`) are implemented
|
|
311
|
+
and live-tested but never offered by default - name them explicitly to
|
|
312
|
+
interoperate with old servers. The server side needs them enabled too:
|
|
267
313
|
|
|
268
314
|
```bash
|
|
269
315
|
# sshd_config on the old box
|
|
@@ -376,7 +422,7 @@ import asyncio
|
|
|
376
422
|
from ccssh import AsyncCCSSHClient
|
|
377
423
|
|
|
378
424
|
async def go():
|
|
379
|
-
c = AsyncCCSSHClient(host_policy="off") # same knobs as sync (
|
|
425
|
+
c = AsyncCCSSHClient(host_policy="off") # same knobs as sync (incl. compression/compression_level)
|
|
380
426
|
await c.connect("host", username="u", password="pw", family=0)
|
|
381
427
|
out, err, code = await c.exec_command("echo hi")
|
|
382
428
|
await c.rekey()
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SSH agent (RFC 9987 core + OpenSSH PROTOCOL.agent): local key use and
|
|
3
|
+
agent forwarding (`ssh -A`).
|
|
4
|
+
|
|
5
|
+
Two halves:
|
|
6
|
+
|
|
7
|
+
* :class:`AgentClient` talks to a local ``ssh-agent`` over its Unix-domain
|
|
8
|
+
socket (``$SSH_AUTH_SOCK`` by default): list public identities and ask
|
|
9
|
+
the agent to sign userauth blobs. Private key material never leaves the
|
|
10
|
+
agent process.
|
|
11
|
+
* Forwarding: after ``auth-agent-req@openssh.com`` on a session channel,
|
|
12
|
+
the server opens ``auth-agent@openssh.com`` channels back; each one is
|
|
13
|
+
relayed byte-for-byte to the local agent socket (see
|
|
14
|
+
``ccssh.forward.AgentForwarder``).
|
|
15
|
+
|
|
16
|
+
Wire framing on the agent socket is ``uint32 length + payload`` (NOT SSH
|
|
17
|
+
binary packets). Message numbers (stable since the original ssh.com agent,
|
|
18
|
+
now RFC 9987):
|
|
19
|
+
|
|
20
|
+
* 11 request-identities, 12 identities-answer
|
|
21
|
+
* 13 sign-request, 14 sign-response
|
|
22
|
+
* 5 failure, 6 success
|
|
23
|
+
* sign flags: 2 = RSA-SHA2-256, 4 = RSA-SHA2-512 (0 otherwise)
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import socket
|
|
29
|
+
import struct
|
|
30
|
+
|
|
31
|
+
from ccssh import wire as W
|
|
32
|
+
|
|
33
|
+
REQ_IDENTITIES = 11
|
|
34
|
+
IDENTITIES_ANSWER = 12
|
|
35
|
+
SIGN_REQUEST = 13
|
|
36
|
+
SIGN_RESPONSE = 14
|
|
37
|
+
AGENT_FAILURE = 5
|
|
38
|
+
AGENT_SUCCESS = 6
|
|
39
|
+
|
|
40
|
+
FLAG_RSA_SHA2_256 = 2
|
|
41
|
+
FLAG_RSA_SHA2_512 = 4
|
|
42
|
+
|
|
43
|
+
MAX_AGENT_MSG = 256 * 1024 # one agent message must never OOM us
|
|
44
|
+
MAX_IDENTITIES = 256 # a rogue agent listing thousands of keys fails closed
|
|
45
|
+
MAX_BLOB = 16384 # same bound as userauth probes (see auth.py)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AgentError(Exception):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def sign_flags_for_alg(alg: str) -> int:
|
|
53
|
+
"""Agent sign-request flags for a userauth algorithm name."""
|
|
54
|
+
if alg == "rsa-sha2-256":
|
|
55
|
+
return FLAG_RSA_SHA2_256
|
|
56
|
+
if alg == "rsa-sha2-512":
|
|
57
|
+
return FLAG_RSA_SHA2_512
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def default_agent_path() -> str | None:
|
|
62
|
+
"""$SSH_AUTH_SOCK when set and non-empty, else None."""
|
|
63
|
+
p = os.environ.get("SSH_AUTH_SOCK")
|
|
64
|
+
if p and p.strip():
|
|
65
|
+
return p
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class AgentClient:
|
|
70
|
+
"""
|
|
71
|
+
One-shot-per-call client for a local agent socket.
|
|
72
|
+
|
|
73
|
+
A fresh Unix connection is opened per call (like OpenSSH's
|
|
74
|
+
ssh_get_agent_identities: short-lived, no shared state), so concurrent
|
|
75
|
+
auth attempts never interleave agent framing.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, path: str | None = None, *, timeout: float = 10.0):
|
|
79
|
+
resolved = path if path is not None else default_agent_path()
|
|
80
|
+
if not resolved:
|
|
81
|
+
raise AgentError("no agent socket (set SSH_AUTH_SOCK or pass agent_path)")
|
|
82
|
+
self.path = resolved
|
|
83
|
+
self.timeout = timeout
|
|
84
|
+
|
|
85
|
+
def _transact(self, payload: bytes) -> bytes:
|
|
86
|
+
if len(payload) > MAX_AGENT_MSG:
|
|
87
|
+
raise AgentError("agent request too big")
|
|
88
|
+
try:
|
|
89
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
90
|
+
except OSError as e:
|
|
91
|
+
raise AgentError(f"cannot create agent socket: {e}") from e
|
|
92
|
+
try:
|
|
93
|
+
s.settimeout(self.timeout)
|
|
94
|
+
try:
|
|
95
|
+
s.connect(self.path)
|
|
96
|
+
except OSError as e:
|
|
97
|
+
raise AgentError(f"cannot connect to agent {self.path}: {e}") from e
|
|
98
|
+
try:
|
|
99
|
+
s.sendall(struct.pack(">I", len(payload)) + payload)
|
|
100
|
+
hdr = _recvn(s, 4)
|
|
101
|
+
(ln,) = struct.unpack(">I", hdr)
|
|
102
|
+
if ln == 0 or ln > MAX_AGENT_MSG:
|
|
103
|
+
raise AgentError(f"bad agent reply length {ln}")
|
|
104
|
+
body = _recvn(s, ln)
|
|
105
|
+
except (OSError, AgentError):
|
|
106
|
+
raise
|
|
107
|
+
except Exception as e:
|
|
108
|
+
raise AgentError(f"agent I/O failed: {e}") from e
|
|
109
|
+
finally:
|
|
110
|
+
try:
|
|
111
|
+
s.close()
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
if not body:
|
|
115
|
+
raise AgentError("empty agent reply")
|
|
116
|
+
return body
|
|
117
|
+
|
|
118
|
+
def list_identities(self) -> list[tuple[bytes, str]]:
|
|
119
|
+
"""
|
|
120
|
+
Returns [(public_blob, comment)]. Fails closed on any malformed
|
|
121
|
+
data (a hostile $SSH_AUTH_SOCK must never inject keys silently).
|
|
122
|
+
"""
|
|
123
|
+
body = self._transact(bytes([REQ_IDENTITIES]))
|
|
124
|
+
r = W.Reader(body)
|
|
125
|
+
try:
|
|
126
|
+
m = r.byte()
|
|
127
|
+
except W.WireError as e:
|
|
128
|
+
raise AgentError(f"bad agent reply: {e}") from e
|
|
129
|
+
if m == AGENT_FAILURE:
|
|
130
|
+
raise AgentError("agent refused identities request")
|
|
131
|
+
if m != IDENTITIES_ANSWER:
|
|
132
|
+
raise AgentError(f"agent sent unexpected message {m}")
|
|
133
|
+
try:
|
|
134
|
+
n = r.u32()
|
|
135
|
+
except W.WireError as e:
|
|
136
|
+
raise AgentError(f"bad agent identities: {e}") from e
|
|
137
|
+
if n > MAX_IDENTITIES:
|
|
138
|
+
raise AgentError(f"agent listed too many identities ({n})")
|
|
139
|
+
out: list[tuple[bytes, str]] = []
|
|
140
|
+
try:
|
|
141
|
+
for _ in range(n):
|
|
142
|
+
blob = r.string()
|
|
143
|
+
comment = r.string().decode("utf-8", errors="replace")
|
|
144
|
+
if not blob or len(blob) > MAX_BLOB:
|
|
145
|
+
raise AgentError("bad agent key blob length")
|
|
146
|
+
out.append((blob, comment))
|
|
147
|
+
r.assert_end()
|
|
148
|
+
except W.WireError as e:
|
|
149
|
+
raise AgentError(f"bad agent identities: {e}") from e
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
def sign(self, key_blob: bytes, data: bytes, flags: int = 0) -> bytes:
|
|
153
|
+
"""
|
|
154
|
+
Ask the agent to sign ``data`` with the key matching ``key_blob``.
|
|
155
|
+
|
|
156
|
+
Returns the nested userauth signature (``string(alg) +
|
|
157
|
+
string(sig)``), ready for ``auth.enc_pubkey_signed``. Raises
|
|
158
|
+
AgentError when the agent refuses, the key is unknown, or the
|
|
159
|
+
reply is malformed.
|
|
160
|
+
"""
|
|
161
|
+
if not key_blob or len(key_blob) > MAX_BLOB:
|
|
162
|
+
raise AgentError("bad agent key blob")
|
|
163
|
+
if len(data) > 65536:
|
|
164
|
+
raise AgentError("agent sign data too big")
|
|
165
|
+
req = (bytes([SIGN_REQUEST]) + W.enc_string(key_blob)
|
|
166
|
+
+ W.enc_string(data) + W.enc_u32(flags))
|
|
167
|
+
body = self._transact(req)
|
|
168
|
+
r = W.Reader(body)
|
|
169
|
+
try:
|
|
170
|
+
m = r.byte()
|
|
171
|
+
except W.WireError as e:
|
|
172
|
+
raise AgentError(f"bad agent reply: {e}") from e
|
|
173
|
+
if m == AGENT_FAILURE:
|
|
174
|
+
raise AgentError("agent refused to sign (key unknown or locked?)")
|
|
175
|
+
if m != SIGN_RESPONSE:
|
|
176
|
+
raise AgentError(f"agent sent unexpected message {m}")
|
|
177
|
+
try:
|
|
178
|
+
sig = r.string()
|
|
179
|
+
r.assert_end()
|
|
180
|
+
except W.WireError as e:
|
|
181
|
+
raise AgentError(f"bad agent signature: {e}") from e
|
|
182
|
+
if not sig:
|
|
183
|
+
raise AgentError("agent returned empty signature")
|
|
184
|
+
return sig
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _recvn(s: socket.socket, n: int) -> bytes:
|
|
188
|
+
out = b""
|
|
189
|
+
while len(out) < n:
|
|
190
|
+
try:
|
|
191
|
+
chunk = s.recv(n - len(out))
|
|
192
|
+
except socket.timeout as e:
|
|
193
|
+
raise AgentError(f"agent read timeout: {e}") from e
|
|
194
|
+
if not chunk:
|
|
195
|
+
raise AgentError("agent closed the connection")
|
|
196
|
+
out += chunk
|
|
197
|
+
if len(out) > MAX_AGENT_MSG + 4:
|
|
198
|
+
raise AgentError("agent reply too big")
|
|
199
|
+
return out
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def dec_agent_open(payload: bytes) -> int:
|
|
203
|
+
"""
|
|
204
|
+
Parse an inbound ``auth-agent@openssh.com`` CHANNEL_OPEN.
|
|
205
|
+
|
|
206
|
+
The type carries no extra fields (just sender/window/maxpacket);
|
|
207
|
+
returns the sender id. Raises AgentError on malformed data.
|
|
208
|
+
"""
|
|
209
|
+
from ccssh import channel as CH
|
|
210
|
+
r = W.Reader(payload)
|
|
211
|
+
try:
|
|
212
|
+
if r.byte() != CH.MSG_CHANNEL_OPEN:
|
|
213
|
+
raise AgentError("not a CHANNEL_OPEN")
|
|
214
|
+
ctype = r.string()
|
|
215
|
+
if ctype != b"auth-agent@openssh.com":
|
|
216
|
+
raise AgentError(f"not an agent open (got {ctype!r})")
|
|
217
|
+
sender = r.u32()
|
|
218
|
+
r.u32()
|
|
219
|
+
r.u32()
|
|
220
|
+
r.assert_end()
|
|
221
|
+
except W.WireError as e:
|
|
222
|
+
raise AgentError(f"bad agent open: {e}") from e
|
|
223
|
+
return sender
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Algorithm registry: what ccssh supports vs OpenSSH (from local `ssh -Q`).
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
8
|
hardware token at runtime - see ccssh.sk; all of its software side is
|
|
9
9
|
implemented and tested.)
|
|
10
10
|
"""
|
|
@@ -81,6 +81,16 @@ OPENSSH_KEYS = [
|
|
|
81
81
|
"ssh-rsa",
|
|
82
82
|
"ssh-rsa-cert-v01@openssh.com",
|
|
83
83
|
]
|
|
84
|
+
# Compression methods (RFC 4253 section 6.2 + delayed draft).
|
|
85
|
+
# `ssh -Q compression` on OpenSSH 10.6 reports only these two: plain `zlib`
|
|
86
|
+
# (immediate, post-NEWKEYS) was dropped server-side and lives in LEGACY for
|
|
87
|
+
# old boxes only. Default proposal is "none,zlib@openssh.com" (no compression
|
|
88
|
+
# unless requested, exactly like OpenSSH); -C / Compression=yes swaps it to
|
|
89
|
+
# "zlib@openssh.com,none".
|
|
90
|
+
OPENSSH_COMPRESSION = [
|
|
91
|
+
"none",
|
|
92
|
+
"zlib@openssh.com",
|
|
93
|
+
]
|
|
84
94
|
|
|
85
95
|
"""
|
|
86
96
|
Actually implemented and covered by loopback + live tests.
|
|
@@ -133,6 +143,18 @@ SUPPORTED_HOSTKEYS = [
|
|
|
133
143
|
]
|
|
134
144
|
# Certificate host keys carry full CA verification (see certs.py), not opaque trust.
|
|
135
145
|
|
|
146
|
+
# Compression: modern OpenSSH offers "none,zlib@openssh.com" by default
|
|
147
|
+
# (disabled) and "zlib@openssh.com,none" with -C/Compression=yes. Plain
|
|
148
|
+
# "zlib" (immediate, pre-auth) is legacy: implemented for old servers,
|
|
149
|
+
# never offered unless named explicitly.
|
|
150
|
+
SUPPORTED_COMPRESSION = [
|
|
151
|
+
"none",
|
|
152
|
+
"zlib@openssh.com",
|
|
153
|
+
]
|
|
154
|
+
LEGACY_COMPRESSION = [
|
|
155
|
+
"zlib",
|
|
156
|
+
]
|
|
157
|
+
|
|
136
158
|
LEGACY_KEX = [
|
|
137
159
|
"diffie-hellman-group-exchange-sha1",
|
|
138
160
|
"diffie-hellman-group1-sha1"
|
|
@@ -281,10 +303,55 @@ def default_kex() -> list[str]:
|
|
|
281
303
|
return [k for k in SUPPORTED_KEX if "sntrup761" not in k]
|
|
282
304
|
|
|
283
305
|
|
|
306
|
+
def default_compression(enabled: bool = False) -> list[str]:
|
|
307
|
+
"""
|
|
308
|
+
Default compression offer, exactly like OpenSSH (see myproposal.h).
|
|
309
|
+
|
|
310
|
+
Disabled (default): ["none", "zlib@openssh.com"] - negotiates to
|
|
311
|
+
"none" with any modern server (client preference wins). Enabled
|
|
312
|
+
(-C / Compression=yes): ["zlib@openssh.com", "none"] - negotiates
|
|
313
|
+
to delayed compression. Plain "zlib" is legacy and never included
|
|
314
|
+
unless named explicitly (see LEGACY_COMPRESSION).
|
|
315
|
+
"""
|
|
316
|
+
if enabled:
|
|
317
|
+
return ["zlib@openssh.com", "none"]
|
|
318
|
+
return ["none", "zlib@openssh.com"]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def normalize_compression(value: bool | str | list[str] | None) -> list[str] | None:
|
|
322
|
+
"""
|
|
323
|
+
Resolve a compression option to an explicit offer list.
|
|
324
|
+
|
|
325
|
+
True -> enabled offer, False -> disabled offer (OpenSSH defaults),
|
|
326
|
+
None -> pass through (caller falls back), str -> single name or
|
|
327
|
+
comma list, list -> via normalize_list against the full capability
|
|
328
|
+
set (SUPPORTED + LEGACY, so "+zlib" works for old servers).
|
|
329
|
+
"""
|
|
330
|
+
if value is None:
|
|
331
|
+
return None
|
|
332
|
+
if isinstance(value, bool):
|
|
333
|
+
return default_compression(value)
|
|
334
|
+
if isinstance(value, str):
|
|
335
|
+
s = value.strip()
|
|
336
|
+
if not s:
|
|
337
|
+
raise ValueError("empty compression option")
|
|
338
|
+
if "," in s:
|
|
339
|
+
vals = [v.strip() for v in s.split(",")]
|
|
340
|
+
else:
|
|
341
|
+
vals = [s]
|
|
342
|
+
return normalize_list(SUPPORTED_COMPRESSION + LEGACY_COMPRESSION, vals) or []
|
|
343
|
+
base = SUPPORTED_COMPRESSION + LEGACY_COMPRESSION
|
|
344
|
+
out = normalize_list(base, list(value))
|
|
345
|
+
if not out:
|
|
346
|
+
raise ValueError("compression list is empty")
|
|
347
|
+
return out
|
|
348
|
+
|
|
349
|
+
|
|
284
350
|
def coverage() -> dict[str, str]:
|
|
285
351
|
return {
|
|
286
352
|
"kex": f"{len(SUPPORTED_KEX)}/{len(OPENSSH_KEX)} live (sntrup761 needs liboqs)",
|
|
287
353
|
"cipher": f"{len(SUPPORTED_CIPHERS)}/{len(OPENSSH_CIPHERS)} live",
|
|
288
354
|
"mac": f"{len(SUPPORTED_MACS)}/{len(OPENSSH_MACS)} live (+AEAD implicit)",
|
|
289
355
|
"hostkey": f"{len(SUPPORTED_HOSTKEYS)}/{len(OPENSSH_KEYS)} live (certs with CA verify)",
|
|
356
|
+
"compression": f"{len(SUPPORTED_COMPRESSION)}/{len(OPENSSH_COMPRESSION)} live (+legacy zlib)",
|
|
290
357
|
}
|