sshcatch 0.2.1__tar.gz → 0.2.2__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.
@@ -4,6 +4,35 @@ All notable changes to **sshcatch** are documented here.
4
4
  This project follows [Keep a Changelog](https://keepachangelog.com/) and
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.2.2] - 2026-07-31
8
+
9
+ Multiple host keys now used as default.
10
+
11
+ ### Added
12
+
13
+ - **Three host keys instead of one.** ed25519, RSA-3072 and ecdsa-nistp256 are
14
+ generated on first run if missing and stored together in one `sshcatch_host_key`
15
+ file, so clients can pick their algorithm. Existing key files are read with
16
+ `read_private_key_list()` and may hold any number of keys.
17
+
18
+ ### Changed
19
+
20
+ - The startup summary prints the fingerprint of each key.
21
+ - **Startup failures go through a single handler.** `PermissionError` is no longer
22
+ special-cased: it always blamed the port, even when the host key file was the
23
+ real problem. The underlying error is shown instead - it already names the
24
+ address and port.
25
+
26
+ ### Fixed
27
+
28
+ - An empty, unreadable or passphrase-protected host key file now ends in a clean
29
+ error message instead of a traceback.
30
+ - `authorized_keys` lines that do not start with a key say why they were refused.
31
+ Options in front of the key (`from="..."`, `restrict`, ...) remain unsupported -
32
+ sshcatch cannot enforce them, so such keys are rejected rather than silently
33
+ accepted without their restrictions.
34
+
35
+
7
36
  ## [0.2.1] - 2026-07-29
8
37
 
9
38
  Forwarding hardening and help/logging polish.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sshcatch
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: Quick-deploy SSH server for tunneling and simple SCP transfers - never opens a shell.
5
5
  Project-URL: Homepage, https://github.com/LorenzMap/sshcatch
6
6
  Project-URL: Repository, https://github.com/LorenzMap/sshcatch
@@ -39,6 +39,28 @@ Built on [asyncssh](https://github.com/ronf/asyncssh).
39
39
  This is a pentesting tool. Only point it at systems and networks you are authorized
40
40
  to test.
41
41
 
42
+ ## Why this tool exists
43
+
44
+ - During engagements and CTFs I love to use 'simple' tools on my host that just work
45
+ - http-server -> `python3 -m http.server`
46
+ - smb-server -> `impacket-smbserver`
47
+ - ssh-server -> ??? (now `sshcatch`)
48
+
49
+ - `sshd` can be used, but:
50
+ - configuring it through `sshd_configs` is a pain
51
+ - multiple use-cases require different configs (tunnel direction? sftp direction? different ports?)
52
+ - logins are controlled by the OS so a user must be created (and secured)
53
+ - I want to restrict the shell so my host is safe
54
+
55
+ - My solution: `sshcatch`
56
+ - Simply configure through clear flags and arguments on the commandline
57
+ - restrictive defaults, every feature must be enabled consciously
58
+ - Never allow shells (or commands)
59
+ - Forward/Reverse tunnels can be individually activated
60
+ - SCP/SFTP file uploads and downloads can be individually activated
61
+ - restrictive upload handling to prevent overwriting
62
+ - symlinks denied
63
+
42
64
  ## Install
43
65
 
44
66
  With `pipx` (recommended, installs into an isolated environment and puts
@@ -62,8 +84,9 @@ cd sshcatch
62
84
  pipx install . # or: pip install .
63
85
  ```
64
86
 
65
- Needs Python 3.10+. A host key is auto-generated in the working directory on
66
- first run (or point `--host-key` at your own).
87
+ Needs Python 3.10+. Three host keys (ed25519, RSA, ECDSA) are auto-generated
88
+ into a single file in the working directory on first run (or point `--host-key`
89
+ at your own file).
67
90
 
68
91
  ## How it works
69
92
 
@@ -99,6 +122,18 @@ Uploads **never overwrite** an existing file. The new file gets a numeric suffix
99
122
 
100
123
  Renames, deletes and directory removal are denied.
101
124
 
125
+ ## Word of Warning
126
+
127
+ Only using `--version-banner` obviously isn't enough deception because the KEXINIT
128
+ that is transferred cleartext on the wire is a clear tell. This differing HASSH
129
+ can be easily detected by a sufficiently sophisticated observer.
130
+
131
+ Also: `sshcatch` is **NOT** designed to be a **honeypot**. Advanced deception, long-term logging
132
+ and everything else a real honeypot needs are deliberately out of scope. There are other
133
+ projects that can be used: [Cowrie](https://github.com/cowrie/cowrie),
134
+ [cyanide-framework](https://github.com/tanhiowyatt/cyanide-framework) and probably a lot more!
135
+
136
+
102
137
  ## Examples
103
138
 
104
139
  Let one user pull/put files from the current directory via SCP/SFTP:
@@ -174,7 +209,9 @@ options:
174
209
  -b BIND, --bind BIND bind address (default: all IPv4/v6 interfaces)
175
210
  -1, --single close the listener after first successful auth (and
176
211
  exit when that connection ends)
177
- --host-key FILE server host key file (default: auto-generate)
212
+ --host-key FILE server host key file, may hold several keys - auto-
213
+ generated if missing - uses ./sshcatch_host_key by
214
+ default
178
215
  --version show program's version number and exit
179
216
 
180
217
  authentication:
@@ -13,6 +13,28 @@ Built on [asyncssh](https://github.com/ronf/asyncssh).
13
13
  This is a pentesting tool. Only point it at systems and networks you are authorized
14
14
  to test.
15
15
 
16
+ ## Why this tool exists
17
+
18
+ - During engagements and CTFs I love to use 'simple' tools on my host that just work
19
+ - http-server -> `python3 -m http.server`
20
+ - smb-server -> `impacket-smbserver`
21
+ - ssh-server -> ??? (now `sshcatch`)
22
+
23
+ - `sshd` can be used, but:
24
+ - configuring it through `sshd_configs` is a pain
25
+ - multiple use-cases require different configs (tunnel direction? sftp direction? different ports?)
26
+ - logins are controlled by the OS so a user must be created (and secured)
27
+ - I want to restrict the shell so my host is safe
28
+
29
+ - My solution: `sshcatch`
30
+ - Simply configure through clear flags and arguments on the commandline
31
+ - restrictive defaults, every feature must be enabled consciously
32
+ - Never allow shells (or commands)
33
+ - Forward/Reverse tunnels can be individually activated
34
+ - SCP/SFTP file uploads and downloads can be individually activated
35
+ - restrictive upload handling to prevent overwriting
36
+ - symlinks denied
37
+
16
38
  ## Install
17
39
 
18
40
  With `pipx` (recommended, installs into an isolated environment and puts
@@ -36,8 +58,9 @@ cd sshcatch
36
58
  pipx install . # or: pip install .
37
59
  ```
38
60
 
39
- Needs Python 3.10+. A host key is auto-generated in the working directory on
40
- first run (or point `--host-key` at your own).
61
+ Needs Python 3.10+. Three host keys (ed25519, RSA, ECDSA) are auto-generated
62
+ into a single file in the working directory on first run (or point `--host-key`
63
+ at your own file).
41
64
 
42
65
  ## How it works
43
66
 
@@ -73,6 +96,18 @@ Uploads **never overwrite** an existing file. The new file gets a numeric suffix
73
96
 
74
97
  Renames, deletes and directory removal are denied.
75
98
 
99
+ ## Word of Warning
100
+
101
+ Only using `--version-banner` obviously isn't enough deception because the KEXINIT
102
+ that is transferred cleartext on the wire is a clear tell. This differing HASSH
103
+ can be easily detected by a sufficiently sophisticated observer.
104
+
105
+ Also: `sshcatch` is **NOT** designed to be a **honeypot**. Advanced deception, long-term logging
106
+ and everything else a real honeypot needs are deliberately out of scope. There are other
107
+ projects that can be used: [Cowrie](https://github.com/cowrie/cowrie),
108
+ [cyanide-framework](https://github.com/tanhiowyatt/cyanide-framework) and probably a lot more!
109
+
110
+
76
111
  ## Examples
77
112
 
78
113
  Let one user pull/put files from the current directory via SCP/SFTP:
@@ -148,7 +183,9 @@ options:
148
183
  -b BIND, --bind BIND bind address (default: all IPv4/v6 interfaces)
149
184
  -1, --single close the listener after first successful auth (and
150
185
  exit when that connection ends)
151
- --host-key FILE server host key file (default: auto-generate)
186
+ --host-key FILE server host key file, may hold several keys - auto-
187
+ generated if missing - uses ./sshcatch_host_key by
188
+ default
152
189
  --version show program's version number and exit
153
190
 
154
191
  authentication:
@@ -9,6 +9,7 @@ import asyncio
9
9
  import logging
10
10
  import os
11
11
  import posixpath
12
+ import re
12
13
  import sys
13
14
  import time
14
15
  from pathlib import Path
@@ -16,7 +17,7 @@ from itertools import count
16
17
 
17
18
  import asyncssh
18
19
 
19
- __version__ = "0.2.1"
20
+ __version__ = "0.2.2"
20
21
 
21
22
  # ── Logging ───────────────────────────────────────────────────────────
22
23
 
@@ -366,10 +367,11 @@ def make_server_factory(args, single_future=None):
366
367
  if args.authorized_keys:
367
368
  for i, line in enumerate(args.authorized_keys.read_text().splitlines(), 1):
368
369
  line = line.strip()
369
- if not line or line.startswith('#'):
370
- continue
370
+ if not line or line.startswith('#'): continue
371
+ key_only = re.search(r"^\S+ AAAA\S+", line)
371
372
  try:
372
- k = asyncssh.import_public_key(line)
373
+ if not key_only: raise ValueError("Must start with 'keytype base64' (no options)")
374
+ k = asyncssh.import_public_key(key_only.group())
373
375
  fp = k.get_fingerprint()
374
376
  auth_keys_fps.add(fp)
375
377
  log_info(f"Loaded key line={i} fingerprint={fp}")
@@ -543,24 +545,31 @@ def make_server_factory(args, single_future=None):
543
545
 
544
546
  # ── Server start ──────────────────────────────────────────────────────
545
547
 
548
+ HOST_KEY_ALGS = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256")
549
+ HOST_KEY_OPTS = {"ssh-rsa": {"key_size": 3072}}
550
+
551
+
546
552
  async def start_server(args):
547
- # Handle Host key
553
+ # Handle Host keys
548
554
  key_path = args.host_key if args.host_key else Path.cwd()
549
- if key_path.is_dir():
555
+ if key_path.is_dir():
550
556
  key_path = key_path / "sshcatch_host_key"
551
557
  if not key_path.parent.is_dir():
552
558
  raise FileNotFoundError(f"Host key directory does not exist: {key_path.parent}")
553
559
 
554
560
  if key_path.is_file():
555
- host_key = asyncssh.read_private_key(str(key_path))
556
- log_info(f"Read host key: {key_path}")
561
+ try: host_keys = asyncssh.read_private_key_list(str(key_path))
562
+ except ValueError as e: raise ValueError(f"Could not read host key file {key_path}: {e}") from None
563
+ if not host_keys: raise ValueError(f"No usable host key in {key_path}")
564
+ log_info(f"Read host key: {key_path} ({len(host_keys)} key{'s'*(len(host_keys)!=1)})")
557
565
  else:
558
- host_key = asyncssh.generate_private_key("ssh-ed25519")
559
- host_key.write_private_key(str(key_path))
560
- log_info(f"Generated host key: {key_path}")
566
+ host_keys = [asyncssh.generate_private_key(a, **HOST_KEY_OPTS.get(a, {}))
567
+ for a in HOST_KEY_ALGS]
568
+ key_path.write_bytes(b"".join(k.export_private_key() for k in host_keys))
569
+ log_info(f"Generated host keys file: {key_path}")
561
570
  if os.name == "posix": key_path.chmod(0o600)
562
571
  else: log_info("Please make sure the permissions on the host key are securely set!", level=logging.WARNING)
563
- fingerprint = host_key.get_fingerprint()
572
+ fingerprints = [(k.get_algorithm(), k.get_fingerprint()) for k in host_keys]
564
573
 
565
574
  # for single-connection mode - resolving releases the bind on the listen port
566
575
  single_future = asyncio.get_running_loop().create_future() if args.single else None
@@ -569,7 +578,7 @@ async def start_server(args):
569
578
  server_factory, n_users, n_keys = make_server_factory(args, single_future)
570
579
  opts = {
571
580
  "server_factory": server_factory,
572
- "server_host_keys": [host_key],
581
+ "server_host_keys": host_keys,
573
582
  # SFTPv3 only so all transfers use open() and not open56()
574
583
  "sftp_version": 3,
575
584
  }
@@ -636,8 +645,8 @@ async def start_server(args):
636
645
  summary.append(f"Version ....... SSH-2.0-{options.version.decode()}")
637
646
  if args.pre_auth_banner: summary.append(f"Pre-auth ...... {banner_preview(args.pre_auth_banner)}")
638
647
  if args.post_auth_banner: summary.append(f"Post-auth ..... {banner_preview(args.post_auth_banner)}")
639
- summary.append(f"Host key ...... {fingerprint}")
640
648
  summary.append(f"Key file ...... {key_path}")
649
+ for algo, fp in fingerprints: summary.append(f"Host key ...... {fp} ({algo})")
641
650
  log_info("sshcatch\n" + "\n".join(f" {line}" for line in summary), level=logging.WARNING)
642
651
 
643
652
  acceptor = await asyncssh.listen(host=args.bind, port=args.port, options=options)
@@ -687,7 +696,7 @@ examples: (also check README on Github)
687
696
  """
688
697
 
689
698
  def build_parser(full=False):
690
- # Help got to long so splitting it into '-h' and '--help'
699
+ # Help got too long so splitting it into '-h' and '--help'
691
700
  def help_text(short_help=None, long_help=""):
692
701
  if full: return (f"{short_help} " if short_help else "") + long_help
693
702
  else: return short_help if short_help else argparse.SUPPRESS
@@ -710,7 +719,8 @@ def build_parser(full=False):
710
719
  help=help_text(short_help="close the listener after first successful auth",
711
720
  long_help="(and exit when that connection ends)"))
712
721
  parser.add_argument("--host-key", metavar="FILE", type=Path,
713
- help=help_text(long_help="server host key file (default: auto-generate)"))
722
+ help=help_text(long_help="server host key file, may hold several keys "
723
+ "- auto-generated if missing - uses ./sshcatch_host_key by default"))
714
724
  parser.add_argument("--version", action="version",
715
725
  version=f"%(prog)s {__version__}",
716
726
  help=help_text(long_help="show program's version number and exit"))
@@ -803,9 +813,7 @@ def main():
803
813
  parser.error(f"Authorized-keys file not found: {args.authorized_keys}")
804
814
 
805
815
  try: asyncio.run(start_server(args))
806
- except PermissionError:
807
- parser.error(f"Permission denied - port {args.port} requires root")
808
- except OSError as e:
816
+ except (OSError, ValueError) as e:
809
817
  parser.error(f"Could not start server: {e}")
810
818
  except KeyboardInterrupt:
811
819
  print()
File without changes
File without changes
File without changes