Skip to content

Commit 2800c1e

Browse files
authored
Improve authentication in SSH server (#34)
1 parent 3201711 commit 2800c1e

3 files changed

Lines changed: 151 additions & 27 deletions

File tree

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,29 @@ Optional arguments:
100100
SSH server
101101
----------
102102

103-
It is possible to serve the emulation through SSH. Clients with terminals supporting the [kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) can send input directly without X11 forwarding. Otherwise, X11 forwarding (`ssh -X`) can be used as a fallback. Use `gambaterm-ssh --help` for more information. 24-bit color is always true over ssh.
103+
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.
104+
105+
```shell
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...
119+
```
120+
121+
Connect with ssh client:
122+
123+
```shell
124+
$ ssh localhost -p 8022
125+
```
104126

105127

106128
Terminal support

gambaterm/ssh.py

Lines changed: 107 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

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

@@ -251,61 +253,112 @@ def ssh_terminal_handler(
251253
pass
252254

253255

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

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

272294
def begin_auth(self, username: str) -> bool:
273-
return True
295+
return not isinstance(self._gambaterm_authentication, NoAuthentication)
274296

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

280302
def password_auth_supported(self) -> bool:
281-
return bool(self._gambaterm_password)
303+
return isinstance(
304+
self._gambaterm_authentication, (PasswordAndPublicKeyAuthentication,)
305+
)
282306

283307
def validate_password(self, username: str, password: str) -> bool:
284-
assert self._gambaterm_password is not None
285-
return password == self._gambaterm_password
308+
assert isinstance(
309+
self._gambaterm_authentication, PasswordAndPublicKeyAuthentication
310+
)
311+
return hmac.compare_digest(password, self._gambaterm_authentication.password)
286312

287313

288314
async def run_server(
289315
bind: str,
290316
port: int,
291-
password: str | None,
317+
authentication: AuthenticationMethod,
292318
console_cls: type[Console],
293319
namespace: argparse.Namespace,
294320
executor: ThreadPoolExecutor,
295321
) -> 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():
322+
# Gambaterm configuration
323+
gambaterm_config_dir = Path(
324+
os.environ.get("GAMBATERM_CONFIG_DIR", "~/.config/gambaterm")
325+
).expanduser()
326+
server_host_key = gambaterm_config_dir / "ssh_host_key"
327+
config_authorized_keys = gambaterm_config_dir / "authorized_keys"
328+
329+
# User SSH public keys (for authentication)
330+
user_ssh_dir = Path(os.environ.get("GAMBATERM_USER_SSH_DIR", "~/.ssh")).expanduser()
331+
user_authorized_keys = user_ssh_dir / "authorized_keys"
332+
333+
# Generate host key if it does not exist
334+
if not server_host_key.exists():
335+
print(f"Generating SSH host key at {server_host_key}...")
336+
server_host_key.parent.mkdir(parents=True, exist_ok=True)
337+
key = asyncssh.generate_private_key("ssh-ed25519")
338+
server_host_key.write_bytes(key.export_private_key())
339+
server_host_key.chmod(0o600)
340+
server_host_keys = [str(server_host_key)]
341+
342+
# Collect authorized client keys for public key authentication
343+
authorized_client_keys = []
344+
if isinstance(
345+
authentication, (PublicKeyAuthentication, PasswordAndPublicKeyAuthentication)
346+
):
347+
for key_type in ["rsa", "ed25519", "ecdsa"]:
348+
user_public_key = user_ssh_dir / f"id_{key_type}.pub"
349+
if user_public_key.exists():
350+
authorized_client_keys.append(str(user_public_key))
351+
if user_authorized_keys.exists():
352+
authorized_client_keys.append(str(user_authorized_keys))
353+
if config_authorized_keys.exists():
354+
authorized_client_keys.append(str(config_authorized_keys))
355+
if not authorized_client_keys and isinstance(
356+
authentication, PublicKeyAuthentication
357+
):
300358
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"
359+
f"Public key authentication is enabled, but no authorized keys were found.\n"
360+
f"Please add the public keys of allowed clients to {config_authorized_keys}."
304361
)
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)]
309362

310363
# Remove chacha20 from encryption_algs because it's a bit too expensive
311364
encryption_algs = [
@@ -318,7 +371,7 @@ async def run_server(
318371
]
319372

320373
server = await asyncssh.create_server(
321-
lambda: SSHServer(password, console_cls, namespace, executor),
374+
lambda: SSHServer(authentication, console_cls, namespace, executor),
322375
bind,
323376
port,
324377
server_host_keys=server_host_keys,
@@ -328,8 +381,22 @@ async def run_server(
328381
line_editor=False,
329382
reuse_address=True,
330383
)
384+
385+
match authentication:
386+
case NoAuthentication():
387+
print("Authentication disabled (no password nor public key required)")
388+
case PasswordAndPublicKeyAuthentication():
389+
print("Authentication methods:")
390+
print("- Global password")
391+
for key_path in authorized_client_keys:
392+
print(f"- Public keys from: {key_path}")
393+
case PublicKeyAuthentication():
394+
print("Authentication methods:")
395+
for key_path in authorized_client_keys:
396+
print(f"- Public keys from: {key_path}")
331397
bind, port = server.sockets[0].getsockname()
332-
print(f"Running ssh server on {bind}:{port}...", flush=True)
398+
print(f"Running SSH server on {bind}:{port}...", flush=True)
399+
333400
async with server:
334401
# Sleep forever
335402
await asyncio.Future()
@@ -365,19 +432,37 @@ def main(
365432
default=None,
366433
help="Enable password authentification with the given global password",
367434
)
435+
parser.add_argument(
436+
"--no-auth",
437+
action="store_true",
438+
help="Disable authentication altogether (no password nor public key required)",
439+
)
368440

369441
# Parse arguments
370442
namespace = parser.parse_args(parser_args)
371443
bind: str = namespace.__dict__.pop("bind")
372444
port: int = namespace.__dict__.pop("port")
373445
password: str = namespace.__dict__.pop("password")
446+
no_auth: bool = namespace.__dict__.pop("no_auth")
447+
448+
# Determine authentication method
449+
if no_auth and password is None:
450+
authentication: AuthenticationMethod = NoAuthentication()
451+
elif not no_auth and password is not None:
452+
authentication = PasswordAndPublicKeyAuthentication(password)
453+
elif not no_auth and password is None:
454+
authentication = PublicKeyAuthentication()
455+
else:
456+
raise SystemExit(
457+
"Both `--password` and `--no-auth` cannot be provided at the same time"
458+
)
374459

375460
# Run an executor with no limit on the number of threads
376461
try:
377462
with ThreadPoolExecutor(max_workers=32) as executor:
378463
# Run the server in asyncio
379464
asyncio.run(
380-
run_server(bind, port, password, console_cls, namespace, executor)
465+
run_server(bind, port, authentication, console_cls, namespace, executor)
381466
)
382467
except KeyboardInterrupt:
383468
pass

tests/test_gambaterm.py

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

2330

2431
@pytest.mark.parametrize(
@@ -41,7 +48,7 @@ def test_gambaterm(interactive: bool) -> None:
4148
assert "▀ ▄▄ ▀" in result.stdout
4249

4350

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

0 commit comments

Comments
 (0)