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.
Files changed (60) hide show
  1. {ccssh-1.0.0/ccssh.egg-info → ccssh-1.0.1}/PKG-INFO +61 -15
  2. {ccssh-1.0.0 → ccssh-1.0.1}/README.md +59 -13
  3. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/__init__.py +1 -1
  4. ccssh-1.0.1/ccssh/agent.py +223 -0
  5. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/algs.py +71 -4
  6. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/async_client.py +102 -14
  7. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/channel.py +20 -0
  8. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/cli.py +185 -31
  9. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/client.py +295 -40
  10. ccssh-1.0.1/ccssh/compress.py +109 -0
  11. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/forward.py +591 -3
  12. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/kex.py +1 -1
  13. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/mux.py +55 -4
  14. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/shell.py +9 -4
  15. ccssh-1.0.1/ccssh/socks.py +144 -0
  16. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/stub.py +154 -11
  17. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/transport.py +208 -12
  18. ccssh-1.0.1/ccssh/x11.py +148 -0
  19. {ccssh-1.0.0 → ccssh-1.0.1/ccssh.egg-info}/PKG-INFO +61 -15
  20. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh.egg-info/SOURCES.txt +6 -0
  21. {ccssh-1.0.0 → ccssh-1.0.1}/setup.py +2 -2
  22. ccssh-1.0.1/tests/test_compress.py +366 -0
  23. ccssh-1.0.1/tests/test_forward_extra.py +866 -0
  24. {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_security.py +2 -6
  25. {ccssh-1.0.0 → ccssh-1.0.1}/LICENSE +0 -0
  26. {ccssh-1.0.0 → ccssh-1.0.1}/MANIFEST.in +0 -0
  27. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/__main__.py +0 -0
  28. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/auth.py +0 -0
  29. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/certs.py +0 -0
  30. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/chacha.py +0 -0
  31. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/chansock.py +0 -0
  32. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/crypto.py +0 -0
  33. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/dh_groups.py +0 -0
  34. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/gcm.py +0 -0
  35. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/hostcheck.py +0 -0
  36. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/hostkeys.py +0 -0
  37. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/kex_ext.py +0 -0
  38. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/mldsa.py +0 -0
  39. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/mlkem.py +0 -0
  40. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/sftp.py +0 -0
  41. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/sk.py +0 -0
  42. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/sntrup.py +0 -0
  43. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/umac.py +0 -0
  44. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh/wire.py +0 -0
  45. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh.egg-info/dependency_links.txt +0 -0
  46. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh.egg-info/entry_points.txt +0 -0
  47. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh.egg-info/requires.txt +0 -0
  48. {ccssh-1.0.0 → ccssh-1.0.1}/ccssh.egg-info/top_level.txt +0 -0
  49. {ccssh-1.0.0 → ccssh-1.0.1}/setup.cfg +0 -0
  50. {ccssh-1.0.0 → ccssh-1.0.1}/tests/__init__.py +0 -0
  51. {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_ccssh.py +0 -0
  52. {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_core.py +0 -0
  53. {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_fuzz.py +0 -0
  54. {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_legacy.py +0 -0
  55. {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_live_sshd.py +0 -0
  56. {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_mlkem.py +0 -0
  57. {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_mux.py +0 -0
  58. {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_shell.py +0 -0
  59. {ccssh-1.0.0 → ccssh-1.0.1}/tests/test_sk.py +0 -0
  60. {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.0
4
- Summary: From-scratch SSH client in Python (sync + async + CLI), OpenSSH-interop verified
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 -Q cipher # list supported ciphers (mac, kex, key too)
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": ..., "strict": ...}
137
+ print(c.negotiated()) # {"kex": ..., "cipher": ..., "mac": ..., "compression": ..., ...}
127
138
  c.close()
128
139
  ```
129
140
 
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):
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 `-R` cannot share one connection (one forwarder per
271
- connection); either one shares it with exec/sftp/shell freely.
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 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:
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 (no rekey timers yet)
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 -Q cipher # list supported ciphers (mac, kex, key too)
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": ..., "strict": ...}
96
+ print(c.negotiated()) # {"kex": ..., "cipher": ..., "mac": ..., "compression": ..., ...}
86
97
  c.close()
87
98
  ```
88
99
 
89
- Key auth tries each `-i`/`key_filename` in turn, then falls back to the
90
- password - like OpenSSH. If the password method fails, the same secret
91
- is tried via keyboard-interactive (RFC 4256, capped rounds/prompts):
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 `-R` cannot share one connection (one forwarder per
230
- connection); either one shares it with exec/sftp/shell freely.
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 MACs, group1-sha1,
264
- gex-sha1, ssh-rsa host keys) are implemented and live-tested but never
265
- offered by default - name them explicitly to interoperate with old
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 (no rekey timers yet)
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()
@@ -18,4 +18,4 @@ except ImportError as _e:
18
18
  raise
19
19
 
20
20
  __all__ = ["CCSSHClient", "AsyncCCSSHClient", "AuthError", "HostKeyError", "CCSSHError", "CCSSHTimeoutError", "ShellSession"]
21
- __version__ = "1.0.0"
21
+ __version__ = "1.0.1"
@@ -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
- 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
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
  }