"""SSH reader against a local paramiko server that behaves like the restricted printer key.""" import io import json import os import socket import stat import threading import paramiko import pytest from app.sshsource import CanvasRecordReader, HostKeyMismatch, HostKeyUnknown, KeyStore, SSHError from .fakes import record RECORD = record(10001, "", 1000, 10008) class Server(paramiko.ServerInterface): reject_exec = False def __init__(self, allowed: paramiko.PKey): self.allowed = allowed self.commands = [] def check_channel_request(self, kind, chanid): return paramiko.OPEN_SUCCEEDED if kind == "session" else paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED def get_allowed_auths(self, username): return "publickey" def check_auth_publickey(self, username, key): ok = username == "root" and key.get_base64() == self.allowed.get_base64() return paramiko.AUTH_SUCCESSFUL if ok else paramiko.AUTH_FAILED def check_channel_exec_request(self, channel, command): self.commands.append(command) if Server.reject_exec: return False def reply(): # forced command: always the canvas record channel.sendall(json.dumps(RECORD).encode()) channel.send_exit_status(0) channel.close() threading.Timer(0.05, reply).start() # after the exec request is acknowledged return True @pytest.fixture def printer(): host_key = paramiko.Ed25519Key.from_private_key(io.StringIO(_new_key_text())) client_key_text = _new_key_text() client_key = paramiko.Ed25519Key.from_private_key(io.StringIO(client_key_text)) sock = socket.socket() sock.bind(("127.0.0.1", 0)) sock.listen(5) servers = [] stop = threading.Event() def accept(): while not stop.is_set(): try: conn, _ = sock.accept() except OSError: return t = paramiko.Transport(conn) t.add_server_key(host_key) srv = Server(client_key) servers.append((t, srv)) try: t.start_server(server=srv) except Exception: pass threading.Thread(target=accept, daemon=True).start() yield {"port": sock.getsockname()[1], "host_key": host_key, "client_key_text": client_key_text, "servers": servers} stop.set() sock.close() for t, _ in servers: t.close() def _new_key_text(): from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey return Ed25519PrivateKey.generate().private_bytes( serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, serialization.NoEncryption()).decode() def test_key_store_permissions_and_no_secret_in_info(tmp_path, printer): keys = KeyStore(tmp_path) keys.save_key(printer["client_key_text"]) mode = stat.S_IMODE(os.stat(keys.key_path).st_mode) assert mode == 0o600 info = keys.key_info() assert info["fingerprint"].startswith("SHA256:") and info["type"] == "ssh-ed25519" assert "PRIVATE" not in json.dumps(info) def test_rejects_garbage_key(tmp_path): with pytest.raises(SSHError): KeyStore(tmp_path).save_key("not a key") def test_first_contact_requires_confirmation(tmp_path, printer): keys = KeyStore(tmp_path) keys.save_key(printer["client_key_text"]) reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"]) with pytest.raises(HostKeyUnknown): reader.read() result = reader.test() assert result["host_key_pinned"] is False assert result["record"]["channels0"]["weight"] == "10001" keys.pin_host_key("p1", "127.0.0.1", printer["host_key"]) rec = reader.read() assert rec["channels3"]["weight"] == "10008" # transport is reused between reads reader.read() assert len(printer["servers"]) == 3 # refused read, test(), then one transport for both reads reader.close() def test_changed_host_key_is_rejected(tmp_path, printer): keys = KeyStore(tmp_path) keys.save_key(printer["client_key_text"]) other = paramiko.Ed25519Key.from_private_key(io.StringIO(_new_key_text())) keys.pin_host_key("p1", "127.0.0.1", other) reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"]) with pytest.raises(HostKeyMismatch): reader.read() with pytest.raises(HostKeyMismatch): reader.test() def test_wrong_client_key_fails_cleanly(tmp_path, printer): keys = KeyStore(tmp_path) keys.save_key(_new_key_text()) keys.pin_host_key("p1", "127.0.0.1", printer["host_key"]) with pytest.raises(SSHError, match="abgelehnt|fehlgeschlagen"): CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"]).read() def test_reconnects_after_drop(tmp_path, printer): keys = KeyStore(tmp_path) keys.save_key(printer["client_key_text"]) keys.pin_host_key("p1", "127.0.0.1", printer["host_key"]) reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"]) reader.read() for t, _ in printer["servers"]: t.close() assert reader.read()["channels0"]["weight"] == "10001" def test_unreachable_printer(tmp_path, printer): keys = KeyStore(tmp_path) keys.save_key(printer["client_key_text"]) s = socket.socket() s.bind(("127.0.0.1", 0)) port = s.getsockname()[1] s.close() with pytest.raises(SSHError, match="nicht erreichbar"): CanvasRecordReader(keys, "p1", "127.0.0.1", port, timeout=2).read() def test_exec_failure_is_a_clean_error(tmp_path, printer, monkeypatch): monkeypatch.setattr(Server, "reject_exec", True) keys = KeyStore(tmp_path) keys.save_key(printer["client_key_text"]) reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"]) with pytest.raises(SSHError, match="Lesefehler"): reader.test() keys.pin_host_key("p1", "127.0.0.1", printer["host_key"]) with pytest.raises(SSHError, match="Lesefehler"): reader.read()