""" Serverseitiger SSH-Verbindungsaufbau (asyncssh). Zentrales Sicherheitsprinzip (Konzept 4.2/4.4/6.4): der private Schluessel wird pro Verbindung aus der DB geladen, entschluesselt, an asyncssh uebergeben und danach nicht weiter referenziert -- er verlaesst den Serverprozess nie und wird nicht geloggt. Strict Host Key Checking ist Pflicht: ohne gepinnten Fingerprint wird die Verbindung abgelehnt. """ from __future__ import annotations import logging import asyncssh import aiosqlite from app.security.crypto import decrypt_secret logger = logging.getLogger("jumphost.ssh_proxy") class HostNotConfiguredError(Exception): pass class HostKeyMismatchError(Exception): def __init__(self, expected: str | None, observed: str | None) -> None: self.expected = expected self.observed = observed super().__init__(f"Host-Key-Mismatch: erwartet={expected!r} beobachtet={observed!r}") class HostKeyDiscoveryError(Exception): """Wird geworfen, wenn beim Host-Key-Discovery-Versuch (siehe discover_and_store_host_key) ueberhaupt KEIN Fingerprint beobachtet werden konnte (TCP/DNS/Timeout-Fehler VOR dem SSH-Key-Exchange) -- im Unterschied zu einem erwarteten Auth-Fehler NACH dem KEX (siehe dort). Der admin-only Endpunkt (admin/routes.py) faengt dies ab und liefert eine saubere 502 statt eines unbehandelten 500.""" def __init__(self, host_id: int, reason: str) -> None: self.host_id = host_id self.reason = reason super().__init__(f"Host-Key-Ermittlung fuer Host {host_id} fehlgeschlagen: {reason}") class PrivateKeyUnusableError(Exception): """Der hinterlegte private Schluessel laesst sich nicht laden. Haeufigster Fall (und der Grund, warum es diese Klasse gibt): der Schluessel ist passphrasegeschuetzt, aber es ist keine -- oder die falsche -- Passphrase hinterlegt. asyncssh wirft dann KeyImportError("Passphrase must be specified to import encrypted private keys"), was ein ValueError und damit KEIN asyncssh.Error ist -- es rauschte deshalb an saemtlichen Fehlerbehandlungen der WS-Routen vorbei und die Sitzung brach ohne verwertbare Meldung ab. Diese Exception traegt stattdessen einen fuer den Benutzer verstaendlichen deutschen Text, den die WS-Routen direkt an den Client durchreichen. """ def __init__(self, message: str) -> None: super().__init__(message) def import_private_key_material( pem: bytes | str, passphrase: bytes | str | None = None ) -> asyncssh.SSHKey: """Importiert Schluesselmaterial und uebersetzt asyncssh-Importfehler in eine PrivateKeyUnusableError mit klarer Ursachenbeschreibung. Wird an ZWEI Stellen verwendet, bewusst mit identischer Semantik: * beim Verbindungsaufbau (load_private_key_for_host), * bereits beim Anlegen/Rotieren eines Schluessels im Adminbereich (app/admin/routes.py), damit ein unbrauchbarer Schluessel sofort mit HTTP 400 abgelehnt wird, statt erst beim ersten Verbindungs- versuch eines Benutzers aufzufallen. """ if isinstance(pem, str): pem = pem.encode() if isinstance(passphrase, str): passphrase = passphrase.encode() if passphrase == b"": passphrase = None try: return asyncssh.import_private_key(pem, passphrase) except ValueError as exc: # Bewusst ValueError statt der konkreten asyncssh-Klassen: # KeyImportError, KeyEncryptionError und KeyGenerationError sind in # asyncssh allesamt ValueError-Unterklassen, ihre Namen und die # genaue Aufteilung unterscheiden sich aber zwischen asyncssh- # Versionen. Ein Zugriff auf einen in der installierten Version # nicht vorhandenen Klassennamen wuerde hier beim Import des Moduls # knallen -- diese Fassung ist gegen solche Versionsunterschiede # immun. asyncssh.Error (Protokollfehler) ist KEIN ValueError und # wird hier korrekterweise nicht mitgefangen. text = str(exc) if "Passphrase must be specified" in text: raise PrivateKeyUnusableError( "Der private Schluessel ist passphrasegeschuetzt, es ist aber keine " "Passphrase hinterlegt. Die Passphrase im Adminbereich unter " "'SSH-Keys' nachtragen (Schluessel bearbeiten) oder einen " "unverschluesselten Schluessel hinterlegen." ) from exc if passphrase is not None: if "Incorrect passphrase" in text or "MAC" in text or "decrypt" in text.lower(): raise PrivateKeyUnusableError( "Die hinterlegte Passphrase passt nicht zum privaten Schluessel." ) from exc # Gegenprobe: manche asyncssh-Versionen quittieren eine Passphrase # zu einem UNverschluesselten Schluessel mit einem Importfehler. # In dem Fall ist nicht der Schluessel kaputt, sondern die # Passphrase ueberfluessig -- also ohne sie erneut versuchen. try: return asyncssh.import_private_key(pem, None) except ValueError: pass raise PrivateKeyUnusableError( f"Der private Schluessel konnte nicht gelesen werden: {text}" ) from exc class _PinnedHostKeyClient(asyncssh.SSHClient): """Erzwingt Strict Host Key Checking gegen einen fest hinterlegten SHA-256-Fingerprint. Kein automatisches Trust-on-First-Use (TOFU).""" def __init__(self, expected_fingerprint: str | None, *, discovery_mode: bool = False) -> None: self.expected_fingerprint = expected_fingerprint self.discovery_mode = discovery_mode self.observed_fingerprint: str | None = None def validate_host_public_key(self, host, addr, port, key) -> bool: # noqa: D102 self.observed_fingerprint = key.get_fingerprint("sha256") if self.discovery_mode: # Nur ueber den expliziten Admin-Discovery-Endpunkt erreichbar, # niemals im regulaeren Verbindungspfad (siehe admin/routes.py). return True if not self.expected_fingerprint: return False return self.observed_fingerprint == self.expected_fingerprint async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict: cursor = await conn.execute( "SELECT id, hostname, address, port, os_type, protocol, ssh_host_key_fingerprint, " "ssh_username, file_transfer_enabled, host_group_id, " "rdp_username, rdp_domain, rdp_require_nla, clipboard_enabled, rdp_ignore_cert " "FROM hosts WHERE id = ? AND is_active = 1", (host_id,), ) row = await cursor.fetchone() if row is None: raise HostNotConfiguredError(f"Host {host_id} nicht gefunden oder inaktiv") keys = ( "id", "hostname", "address", "port", "os_type", "protocol", "ssh_host_key_fingerprint", "ssh_username", "file_transfer_enabled", "host_group_id", # Bugfix: die folgenden fuenf Spalten wurden bisher NICHT geladen, # obwohl build_rdp_params() (app/rdp_proxy/guacd_client.py) sie per # host.get(...) ausliest. Ergebnis war eine RDP-connect-Instruktion # ohne Benutzernamen und ohne Domaene -- das Zielsystem lehnt die # Anmeldung dann ab bzw. der Client haengt in "Warte auf Server ...". # Zusaetzlich war clipboard_enabled dadurch immer None (= Clipboard # dauerhaft gesperrt, unabhaengig von der Hostkonfiguration). "rdp_username", "rdp_domain", "rdp_require_nla", "clipboard_enabled", "rdp_ignore_cert", ) host = dict(zip(keys, row)) for flag in ("file_transfer_enabled", "rdp_require_nla", "clipboard_enabled", "rdp_ignore_cert"): host[flag] = bool(host[flag]) return host async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey: """Laedt den dem Host zugeordneten Schluessel und entschluesselt ihn. Ist zu dem Schluessel eine Passphrase hinterlegt (ssh_keys.passphrase_enc, Migration 0009), wird sie ebenfalls entschluesselt und an asyncssh uebergeben. Ohne diesen Schritt scheiterte der Import jedes passphrasegeschuetzten Schluessels grundsaetzlich -- siehe PrivateKeyUnusableError. """ cursor = await conn.execute( "SELECT sk.private_key_enc, sk.passphrase_enc FROM ssh_keys sk " "JOIN host_ssh_key_map m ON m.ssh_key_id = sk.id " "WHERE m.host_id = ? LIMIT 1", (host_id,), ) row = await cursor.fetchone() if row is None: raise HostNotConfiguredError(f"Kein SSH-Schluessel fuer Host {host_id} hinterlegt") pem = decrypt_secret(row[0], associated_data=b"ssh_private_key") passphrase = ( decrypt_secret(row[1], associated_data=b"ssh_key_passphrase") if row[1] else None ) try: return import_private_key_material(pem, passphrase) finally: # Bestpraxis: Referenzen auf das Klartextmaterial so schnell wie # moeglich loslassen (Konzept 6.4). del pem del passphrase async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHClientConnection: host = await load_host(conn, host_id) if host["protocol"] != "ssh": raise HostNotConfiguredError("Host ist kein SSH-Ziel") private_key = await load_private_key_for_host(conn, host_id) client_factory = lambda: _PinnedHostKeyClient(host["ssh_host_key_fingerprint"]) try: connection = await asyncssh.connect( host["address"], port=host["port"], username=host["ssh_username"], client_keys=[private_key], known_hosts=None, # Validierung erfolgt ausschliesslich ueber validate_host_public_key client_factory=client_factory, connect_timeout=10, ) except asyncssh.Error as exc: logger.warning("SSH-Verbindungsfehler zu Host %s: %s", host_id, exc) raise return connection async def discover_and_store_host_key( conn: aiosqlite.Connection, host_id: int, *, admin_user_id: int ) -> str: """Verbindet EINMALIG ohne Pinning, um den Host-Key-Fingerprint zu erfassen und in der DB zu hinterlegen. Nur ueber einen dedizierten, admin-only Endpunkt aufrufbar -- jeder Aufruf ist eine bewusste Vertrauensentscheidung und wird im Audit-Log als solche vermerkt (siehe admin/routes.py). Bugfix (Fehler 500 bei 'Host-Key ermitteln'): asyncssh.connect() fuehrt nach dem Key-Exchange (bei dem validate_host_public_key() den Fingerprint bereits erfasst) automatisch die Authentifizierung durch -- ohne hinterlegten Client-Key/Passwort schlaegt die IMMER fehl (asyncssh.Error, z.B. PermissionDenied), obwohl der Fingerprint laengst bekannt ist. Diese fuer die reine Discovery irrelevante Auth-Fehlermeldung wurde bisher NICHT abgefangen und riss als unbehandelte Exception bis zu FastAPI durch -> 500 Internal Server Error bei praktisch jedem Aufruf. Fix: Verbindungs- fehler abfangen; wenn dabei bereits ein Fingerprint beobachtet wurde, gilt die Discovery als erfolgreich. Nur wenn wirklich KEIN Fingerprint erfasst wurde (Host nicht erreichbar, DNS-Fehler, Timeout -- also ein Fehler VOR dem Key-Exchange), ist es ein echter Fehler (HostKeyDiscoveryError).""" host = await load_host(conn, host_id) if host["protocol"] != "ssh": raise HostNotConfiguredError("Host-Key-Ermittlung ist nur fuer SSH-Ziele moeglich") client = _PinnedHostKeyClient(None, discovery_mode=True) connection = None try: connection = await asyncssh.connect( host["address"], port=host["port"], username=host["ssh_username"], known_hosts=None, client_factory=lambda: client, connect_timeout=10, ) except (asyncssh.Error, OSError) as exc: if client.observed_fingerprint is None: logger.warning("Host-Key-Ermittlung fuer Host %s fehlgeschlagen: %s", host_id, exc) raise HostKeyDiscoveryError(host_id, str(exc)) from exc # Fingerprint wurde bereits waehrend des Key-Exchange erfasst -- der # anschliessende Auth-Fehler ist fuer die Discovery unschaedlich. logger.info( "Host-Key fuer Host %s erfasst (Auth-Phase erwartungsgemaess fehlgeschlagen: %s)", host_id, exc, ) finally: if connection is not None: connection.close() fingerprint = client.observed_fingerprint await conn.execute( "UPDATE hosts SET ssh_host_key_fingerprint = ? WHERE id = ?", (fingerprint, host_id) ) await conn.commit() return fingerprint