Skip to content

Commit c1d9d28

Browse files
committed
Improve authentication in SSH server
1 parent 568c990 commit c1d9d28

3 files changed

Lines changed: 142 additions & 37 deletions

File tree

README.md

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -103,20 +103,25 @@ SSH server
103103
It is possible to serve the emulation through SSH. Clients must use a terminal that supports the [kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/), or use X11 forwarding (`ssh -X`) as a fallback. Use `gambaterm-ssh --help` for more information. 24-bit color is always assumed over SSH. Audio is not available over SSH.
104104

105105
```shell
106-
$ gambaterm-ssh --password '' myrom.gbc # with no password (press return if prompted)
107-
$ gambaterm-ssh --password '' --bind 0.0.0.0 --port 8022 myrom.gbc # Listen on all interfaces
106+
# Serve `myrom.gbc` locally on port 8022, disabling authentication for local testing
107+
# Note: an SSH host key is automatically generated on the first run
108+
$ gambaterm-ssh --no-auth myrom.gbc
109+
Generating SSH host key at ~/.config/gambaterm/ssh_host_key...
110+
Authentication disabled (no password nor public key required)
111+
Running SSH server on 127.0.0.1:8022...
112+
113+
# Listen on all interfaces, using a global password as authentication method
114+
$ gambaterm-ssh --password mypassword --bind 0.0.0.0 --port 8022 myrom.gbc
115+
Authentication methods:
116+
- Global password
117+
- Public keys from: /home/user/.ssh/id_rsa.pub
118+
Running SSH server on 0.0.0.0:8022...
108119
```
109120

110121
Connect with ssh client:
111122

112-
``shell
113-
ssh localhost -p 8022
114-
```
115-
116-
Suggest removing keystroke timing obfuscation for better button latency:
117-
118123
```shell
119-
ssh -o ObscureKeystrokeTiming=no example.com -p 8022
124+
$ ssh localhost -p 8022
120125
```
121126

122127
Telnet server

gambaterm/ssh.py

Lines changed: 107 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
import os
44
import time
5+
import hmac
56
import hashlib
67
import asyncio
78
import argparse
89
import traceback
910
from pathlib import Path
10-
from typing import IO, Callable, cast, ContextManager
11+
from dataclasses import dataclass
12+
from typing import IO, Callable, TypeAlias, cast, ContextManager
1113
from enum import Enum, auto
1214
from concurrent.futures import ThreadPoolExecutor, CancelledError
1315

@@ -249,63 +251,112 @@ def ssh_terminal_handler(
249251
pass
250252

251253

254+
@dataclass
255+
class PasswordAndPublicKeyAuthentication:
256+
password: str
257+
258+
259+
@dataclass
260+
class PublicKeyAuthentication:
261+
pass
262+
263+
264+
@dataclass
265+
class NoAuthentication:
266+
pass
267+
268+
269+
AuthenticationMethod: TypeAlias = (
270+
PasswordAndPublicKeyAuthentication | PublicKeyAuthentication | NoAuthentication
271+
)
272+
273+
252274
class SSHServer(asyncssh.SSHServer):
253275
def __init__(
254276
self,
255-
password: str | None,
277+
authentication: AuthenticationMethod,
256278
console_cls: type[Console],
257279
namespace: argparse.Namespace,
258280
executor: ThreadPoolExecutor,
259281
):
260282
self._gambaterm_console_cls = console_cls
261283
self._gambaterm_namespace = namespace
262284
self._gambaterm_executor = executor
263-
self._gambaterm_password = password
285+
self._gambaterm_authentication = authentication
264286

265287
def connection_made(self, conn: asyncssh.SSHServerConnection) -> None:
266288
conn.set_extra_info(console_cls=self._gambaterm_console_cls)
267289
conn.set_extra_info(executor=self._gambaterm_executor)
268290
conn.set_extra_info(namespace=self._gambaterm_namespace)
269291

270292
def begin_auth(self, username: str) -> bool:
271-
return True
293+
return not isinstance(self._gambaterm_authentication, NoAuthentication)
272294

273295
def session_requested(self) -> SSHServerProcess[str]:
274296
return asyncssh.SSHServerProcess(
275297
safe_ssh_process_handler, sftp_factory=None, sftp_version=3, allow_scp=False
276298
)
277299

278300
def password_auth_supported(self) -> bool:
279-
# Allow empty string as a valid password (--password ''),
280-
# only None (unset) disables password auth.
281-
return self._gambaterm_password is not None
301+
return isinstance(
302+
self._gambaterm_authentication, (PasswordAndPublicKeyAuthentication,)
303+
)
282304

283305
def validate_password(self, username: str, password: str) -> bool:
284-
assert self._gambaterm_password is not None
285-
return password == self._gambaterm_password
306+
assert isinstance(
307+
self._gambaterm_authentication, PasswordAndPublicKeyAuthentication
308+
)
309+
return hmac.compare_digest(password, self._gambaterm_authentication.password)
286310

287311

288312
async def run_server(
289313
bind: str,
290314
port: int,
291-
password: str | None,
315+
authentication: AuthenticationMethod,
292316
console_cls: type[Console],
293317
namespace: argparse.Namespace,
294318
executor: ThreadPoolExecutor,
295319
) -> None:
296-
ssh_key_dir = Path(os.environ.get("GAMBATERM_SSH_KEY_DIR", "~/.ssh"))
297-
user_private_key = (ssh_key_dir / "id_rsa").expanduser()
298-
user_public_key = (ssh_key_dir / "id_rsa.pub").expanduser()
299-
if not user_private_key.exists():
320+
# Gambaterm configuration
321+
gambaterm_config_dir = Path(
322+
os.environ.get("GAMBATERM_CONFIG_DIR", "~/.config/gambaterm")
323+
).expanduser()
324+
server_host_key = gambaterm_config_dir / "ssh_host_key"
325+
config_authorized_keys = gambaterm_config_dir / "authorized_keys"
326+
327+
# User SSH public keys (for authentication)
328+
user_ssh_dir = Path(os.environ.get("GAMBATERM_USER_SSH_DIR", "~/.ssh")).expanduser()
329+
user_authorized_keys = user_ssh_dir / "authorized_keys"
330+
331+
# Generate host key if it does not exist
332+
if not server_host_key.exists():
333+
print(f"Generating SSH host key at {server_host_key}...")
334+
server_host_key.parent.mkdir(parents=True, exist_ok=True)
335+
key = asyncssh.generate_private_key("ssh-ed25519")
336+
server_host_key.write_bytes(key.export_private_key())
337+
server_host_key.chmod(0o600)
338+
server_host_keys = [str(server_host_key)]
339+
340+
# Collect authorized client keys for public key authentication
341+
authorized_client_keys = []
342+
if isinstance(
343+
authentication, (PublicKeyAuthentication, PasswordAndPublicKeyAuthentication)
344+
):
345+
for key_type in ["rsa", "ed25519", "ecdsa"]:
346+
user_public_key = user_ssh_dir / f"id_{key_type}.pub"
347+
if user_public_key.exists():
348+
authorized_client_keys.append(str(user_public_key))
349+
if user_authorized_keys.exists():
350+
authorized_client_keys.append(str(user_authorized_keys))
351+
if config_authorized_keys.exists():
352+
authorized_client_keys.append(str(config_authorized_keys))
353+
if not authorized_client_keys and isinstance(
354+
authentication, PublicKeyAuthentication
355+
):
300356
raise SystemExit(
301-
f"The server requires a private RSA key to use as a host hey.\n"
302-
f"You may generate one by running the following command:\n\n"
303-
f" ssh-keygen -f {ssh_key_dir / 'id_rsa'} -P ''\n"
357+
f"Public key authentication is enabled, but no authorized keys were found.\n"
358+
f"Please add the public keys of allowed clients to {config_authorized_keys}."
304359
)
305-
server_host_keys = [str(user_private_key)]
306-
authorized_client_keys = []
307-
if user_public_key.exists():
308-
authorized_client_keys = [str(user_public_key)]
309360

310361
# Remove chacha20 from encryption_algs because it's a bit too expensive
311362
encryption_algs = [
@@ -318,7 +369,7 @@ async def run_server(
318369
]
319370

320371
server = await asyncssh.create_server(
321-
lambda: SSHServer(password, console_cls, namespace, executor),
372+
lambda: SSHServer(authentication, console_cls, namespace, executor),
322373
bind,
323374
port,
324375
server_host_keys=server_host_keys,
@@ -328,8 +379,22 @@ async def run_server(
328379
line_editor=False,
329380
reuse_address=True,
330381
)
382+
383+
match authentication:
384+
case NoAuthentication():
385+
print("Authentication disabled (no password nor public key required)")
386+
case PasswordAndPublicKeyAuthentication():
387+
print("Authentication methods:")
388+
print("- Global password")
389+
for key_path in authorized_client_keys:
390+
print(f"- Public keys from: {key_path}")
391+
case PublicKeyAuthentication():
392+
print("Authentication methods:")
393+
for key_path in authorized_client_keys:
394+
print(f"- Public keys from: {key_path}")
331395
bind, port = server.sockets[0].getsockname()
332-
print(f"Running ssh server on {bind}:{port}...", flush=True)
396+
print(f"Running SSH server on {bind}:{port}...", flush=True)
397+
333398
async with server:
334399
# Sleep forever
335400
await asyncio.Future()
@@ -365,19 +430,37 @@ def main(
365430
default=None,
366431
help="Enable password authentification with the given global password",
367432
)
433+
parser.add_argument(
434+
"--no-auth",
435+
action="store_true",
436+
help="Disable authentication altogether (no password nor public key required)",
437+
)
368438

369439
# Parse arguments
370440
namespace = parser.parse_args(parser_args)
371441
bind: str = namespace.__dict__.pop("bind")
372442
port: int = namespace.__dict__.pop("port")
373443
password: str = namespace.__dict__.pop("password")
444+
no_auth: bool = namespace.__dict__.pop("no_auth")
445+
446+
# Determine authentication method
447+
if no_auth and password is None:
448+
authentication: AuthenticationMethod = NoAuthentication()
449+
elif not no_auth and password is not None:
450+
authentication = PasswordAndPublicKeyAuthentication(password)
451+
elif not no_auth and password is None:
452+
authentication = PublicKeyAuthentication()
453+
else:
454+
raise SystemExit(
455+
"Both `--password` and `--no-auth` cannot be provided at the same time"
456+
)
374457

375458
# Run an executor with no limit on the number of threads
376459
try:
377460
with ThreadPoolExecutor(max_workers=32) as executor:
378461
# Run the server in asyncio
379462
asyncio.run(
380-
run_server(bind, port, password, console_cls, namespace, executor)
463+
run_server(bind, port, authentication, console_cls, namespace, executor)
381464
)
382465
except KeyboardInterrupt:
383466
pass

tests/test_gambaterm.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,16 @@ def ssh_config(tmp_path: Path) -> Iterator[Path]:
1717
(tmp_path / "id_rsa.pub").write_bytes(rsa_key.export_public_key())
1818
os.chmod(tmp_path / "id_rsa", 0o600)
1919
os.chmod(tmp_path / "id_rsa.pub", 0o600)
20-
os.environ["GAMBATERM_SSH_KEY_DIR"] = str(tmp_path)
20+
os.environ["GAMBATERM_USER_SSH_DIR"] = str(tmp_path)
2121
yield tmp_path
22-
del os.environ["GAMBATERM_SSH_KEY_DIR"]
22+
del os.environ["GAMBATERM_USER_SSH_DIR"]
23+
24+
25+
@pytest.fixture
26+
def gambaterm_config(tmp_path: Path) -> Iterator[Path]:
27+
os.environ["GAMBATERM_CONFIG_DIR"] = str(tmp_path)
28+
yield tmp_path
29+
del os.environ["GAMBATERM_CONFIG_DIR"]
2330

2431

2532
@pytest.mark.parametrize(
@@ -42,7 +49,7 @@ def test_gambaterm(interactive: bool) -> None:
4249
assert "▀ ▄▄ ▀" in result.stdout
4350

4451

45-
def test_gambaterm_ssh(ssh_config: Path) -> None:
52+
def test_gambaterm_ssh(ssh_config: Path, gambaterm_config: Path) -> None:
4653
assert TEST_ROM.exists()
4754
command = f"gambaterm-ssh {TEST_ROM} --break-after 10 --input-file /dev/null --color-mode 4"
4855
env = os.environ.copy()
@@ -53,7 +60,17 @@ def test_gambaterm_ssh(ssh_config: Path) -> None:
5360
assert server.stdout is not None
5461
assert server.stderr is not None
5562
try:
56-
assert server.stdout.readline() == "Running ssh server on 127.0.0.1:8022...\n"
63+
assert (
64+
server.stdout.readline()
65+
== f"Generating SSH host key at {gambaterm_config / 'ssh_host_key'}...\n"
66+
)
67+
assert server.stdout.readline() == "Authentication methods:\n"
68+
assert (
69+
server.stdout.readline()
70+
== f"- Public keys from: {ssh_config / 'id_rsa.pub'}\n"
71+
)
72+
assert server.stdout.readline() == "Running SSH server on 127.0.0.1:8022...\n"
73+
assert (gambaterm_config / "ssh_host_key").exists()
5774
client = run(
5875
f"ssh -tt -q localhost -p 8022 -X -i {ssh_config / 'id_rsa'} -o StrictHostKeyChecking=no",
5976
shell=True,

0 commit comments

Comments
 (0)