diff --git a/README.md b/README.md index a6c6c87..0448bad 100644 --- a/README.md +++ b/README.md @@ -328,13 +328,123 @@ protokollieren jetzt und geben den Grund als WebSocket-Close-Reason mit, den Passphrase nachtragen (Adminbereich -> Zugangsdaten -> SSH-Key bearbeiten). 3. Bei RDP-Hosts pruefen, dass ein RDP-Benutzername gesetzt ist. +## Phase 11: fehlendes `bcrypt` im Virtualenv + +Symptom: `Der private Schluessel konnte nicht gelesen werden: OpenSSH private +key encryption requires bcrypt with KDF support`. + +Ursache war weder der Schluessel noch die (seit Phase 10 speicherbare) +Passphrase, sondern die **Serverumgebung**: OpenSSH verschluesselt private +Schluessel mit `bcrypt_pbkdf`. asyncssh und `cryptography` fuehren diese +Ableitung nur aus, wenn das Python-Modul `bcrypt` installiert ist -- es ist +bei beiden ein *optionales* Extra (`asyncssh[bcrypt]`) und stand nicht in +`requirements.txt`. Im ausgerollten Virtualenv fehlte es deshalb, und damit +war jeder mit `ssh-keygen` erzeugte passphrasegeschuetzte Schluessel +prinzipiell unlesbar -- unabhaengig davon, ob die Passphrase stimmte. + +Behoben: + +* `bcrypt` steht jetzt als **Pflichtabhaengigkeit** in `requirements.txt` + (gepinnt wie alle anderen; entspricht dem Extra `asyncssh[bcrypt]`). +* `import_private_key_material()` uebersetzt die englische Bibliotheksmeldung + in einen deutschen Klartext, der die Ursache benennt (Serverumgebung, nicht + Schluessel/Passphrase) und den Behebungsweg nennt. Diese Meldung erscheint + im Terminal, im Dateitransfer und als HTTP 400 beim Key-Upload. +* Der Anwendungsstart prueft die Abhaengigkeit einmalig und schreibt eine + ERROR-Zeile ins Log (und damit ins Live-Verbindungslog des Adminbereichs), + statt den Fehler bis zur ersten Benutzersitzung zu verschleppen. + +### Nach dem Deployment zu tun + +```bash +# Ansible: die Rolle python_runtime installiert requirements.txt neu +ansible-playbook -i inventory/production.ini site.yml + +# oder von Hand auf dem Jumphost: +sudo -u jumphost /opt/jumphost/venv/bin/pip install -r /opt/jumphost/requirements.txt +sudo systemctl restart jumphost-app +``` + +Kontrolle: `journalctl -u jumphost-app | grep bcrypt` bleibt nach dem Neustart +leer. Erscheint die ERROR-Zeile, laeuft der Dienst noch gegen ein Virtualenv +ohne `bcrypt`. + +## Phase 12: Benutzername gehoert zu den Zugangsdaten -- und wirksames Host-Key-Pinning + +**Symptom:** `Host-Key konnte nicht ermittelt werden -- Ziel nicht erreichbar: +Permission denied for user l4u on host `. + +Das Ziel war sehr wohl erreichbar: der Key-Exchange war laengst durch, nur die +anschliessende Anmeldung scheiterte. Die Ermittlung lief bisher ueber einen +vollstaendigen `asyncssh.connect()`-Versuch und las den Fingerprint aus dem +Callback `SSHClient.validate_host_public_key()`. **Diesen Callback ruft +asyncssh nur auf, wenn `known_hosts` nicht `None` ist** -- mit +`known_hosts=None` ist die Host-Key-Pruefung komplett abgeschaltet. Der +Fingerprint blieb deshalb leer, und der voellig erwartbare Auth-Fehler wurde +als "Ziel nicht erreichbar" gemeldet. + +**Dieselbe Ursache hatte eine sicherheitsrelevante zweite Wirkung:** der +regulaere Verbindungspfad benutzte exakt dieselbe Kombination. Das +"Strict Host Key Checking" gegen den gepinnten Fingerprint lief also +faktisch nie -- der Server wurde ueberhaupt nicht geprueft. + +Neu: + +* Die Ermittlung benutzt `asyncssh.get_server_host_key()`: reiner + Key-Exchange, gar kein Anmeldeversuch. Ein Auth-Fehler kann sie damit nicht + mehr verhindern. Gespeichert wird jetzt neben dem Fingerprint auch der + **vollstaendige Host-Key** (`hosts.ssh_host_key`, Migration 0010). +* Vor jeder SSH-Sitzung wird der Host-Key des Ziels auf demselben Weg geholt + und gegen den hinterlegten geprueft -- **bevor** Schluesselmaterial + entschluesselt oder eine Anmeldung versucht wird. Weicht er ab, bricht die + Sitzung mit Klartextmeldung ab. Nach dem Sitzungsaufbau wird der Schluessel + der Sitzung noch einmal gegengeprueft (`connection.get_server_host_key()`), + damit auch ein Wechsel zwischen Pruefung und Sitzung auffaellt. +* Ist fuer einen Host **kein** Host-Key hinterlegt, wird die Verbindung + abgelehnt (`HostKeyNotPinnedError`) statt stillschweigend jedem Gegenueber + zu vertrauen. Kein Trust-on-First-Use. +* Fuer Hosts, deren Key vor Migration 0010 ermittelt wurde, wird der + vollstaendige Schluessel beim naechsten Verbindungsaufbau nachgetragen -- + nachdem er gegen den gepinnten Fingerprint geprueft wurde. + +**Benutzername gehoert zur Anmeldung, nicht zum Server (Migration 0010).** +`hosts.ssh_username` / `hosts.rdp_username` / `hosts.rdp_domain` waren am +falschen Objekt: der Anmeldename ist Teil der Zugangsdaten. Ab jetzt: + +* **SSH:** `ssh_keys.username` -- ein Benutzername je Schluessel ("dieser Key + ist der Zugang von l4u"). Fuer ein zweites Konto wird ein zweiter + Schluesseleintrag angelegt. +* **RDP/Windows:** `rdp_credentials.username` und `.domain` -- zusammen mit + dem Passwort, das ohnehin schon dort liegt. Der Benutzername ist beim + Setzen der Zugangsdaten Pflicht. +* Die Felder verschwinden aus dem Host-Formular; das Hostdetail zeigt + stattdessen an, mit welchem Namen die Anmeldung erfolgen wuerde. +* Die Migration uebernimmt die alten Werte: bei RDP eindeutig, bei SSH nur + dann, wenn alle Hosts eines Schluessels denselben Benutzernamen hatten -- + sonst bliebe es Raten. Solche Schluessel bleiben leer und greifen weiter auf + den Host-Wert zurueck, bis ein Admin den Namen am Schluessel setzt (die + Oberflaeche weist darauf hin). +* Die alten Spalten bleiben als Fallback lesbar (ein Spalten-Drop erzwaenge in + SQLite einen Tabellen-Rebuild, siehe Begruendung in `0006_tenants.sql`), die + API nimmt sie als `deprecated` weiterhin entgegen. + +### Nach dem Deployment zu tun + +1. Migration 0010 laeuft beim Start automatisch. +2. Fuer jeden SSH-Key pruefen, ob der Benutzername gesetzt ist + (Adminbereich -> Zugangsdaten -> Spalte "Benutzer"). +3. Bei RDP-Hosts die Zugangsdaten einmal neu speichern (Benutzername + + Domaene + Passwort), falls die Uebernahme nichts gefunden hat. +4. Fuer SSH-Hosts ohne hinterlegten Host-Key einmal "Host-Key ermitteln" + ausfuehren -- ohne ihn wird die Verbindung jetzt abgelehnt. + ## Tests ```bash pytest -q ``` -78 Tests (vorher 68; `tests/test_phase10.py` kam hinzu) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain +105 Tests (vorher 88; `tests/test_phase12.py` kam hinzu) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain (inkl. Manipulationserkennung und Trigger-Durchsetzung), RBAC-Logik inkl. Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment -> Session-Cookie -> geschuetzte Endpunkte) gegen die echte FastAPI-App, 17 diff --git a/app/admin/routes.py b/app/admin/routes.py index ee315e2..d517799 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -811,13 +811,13 @@ async def get_host_detail( if row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Host nicht gefunden") keys_cursor = await conn.execute( - "SELECT sk.id, sk.label FROM host_ssh_key_map m JOIN ssh_keys sk ON sk.id = m.ssh_key_id " - "WHERE m.host_id = ?", + "SELECT sk.id, sk.label, sk.username FROM host_ssh_key_map m " + "JOIN ssh_keys sk ON sk.id = m.ssh_key_id WHERE m.host_id = ?", (host_id,), ) - ssh_keys = [{"id": k[0], "label": k[1]} for k in await keys_cursor.fetchall()] + ssh_keys = [{"id": k[0], "label": k[1], "username": k[2]} for k in await keys_cursor.fetchall()] rdp_row = await (await conn.execute( - "SELECT updated_at FROM rdp_credentials WHERE host_id = ?", (host_id,) + "SELECT updated_at, username, domain FROM rdp_credentials WHERE host_id = ?", (host_id,) )).fetchone() return { "id": row[0], "hostname": row[1], "address": row[2], "protocol": row[3], "port": row[4], @@ -829,6 +829,10 @@ async def get_host_detail( "ssh_keys": ssh_keys, "rdp_credentials_set": rdp_row is not None, "rdp_credentials_updated_at": rdp_row[0] if rdp_row else None, + # Benutzername/Domaene gehoeren seit Migration 0010 zu den + # Zugangsdaten; sie werden hier nur zur Anzeige mitgeliefert. + "rdp_credentials_username": rdp_row[1] if rdp_row else None, + "rdp_credentials_domain": rdp_row[2] if rdp_row else None, } @@ -955,17 +959,28 @@ async def set_rdp_credentials( conn = get_db() if admin.is_any_admin: await _assert_host_in_scope(conn, _scope(admin), host_id) + username = (payload.username or "").strip() or None + if username is None: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "Bitte den Windows-Benutzernamen mit angeben -- er gehoert zu den " + "Zugangsdaten und wird fuer die Anmeldung am Ziel gebraucht.", + ) + domain = (payload.domain or "").strip() or None encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password") await conn.execute( - "INSERT INTO rdp_credentials (host_id, password_enc, updated_at) " - "VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) " + "INSERT INTO rdp_credentials (host_id, password_enc, username, domain, updated_at) " + "VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) " "ON CONFLICT(host_id) DO UPDATE SET password_enc = excluded.password_enc, " + "username = excluded.username, domain = excluded.domain, " "updated_at = excluded.updated_at", - (host_id, encrypted), + (host_id, encrypted, username, domain), ) await write_audit_event( conn, event_type="rdp_credentials_set", user_id=admin.id, client_ip=_client_ip(request), - details={"host_id": host_id}, + # Benutzername/Domaene sind keine Geheimnisse (das Passwort schon) und + # gehoeren ins Audit-Log. + details={"host_id": host_id, "username": username, "domain": domain}, ) await conn.commit() return {"status": "ok"} @@ -1011,18 +1026,20 @@ async def get_host_credentials( if host_row is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Host nicht gefunden") keys_cursor = await conn.execute( - "SELECT sk.id, sk.label FROM host_ssh_key_map m JOIN ssh_keys sk ON sk.id = m.ssh_key_id " - "WHERE m.host_id = ?", + "SELECT sk.id, sk.label, sk.username FROM host_ssh_key_map m " + "JOIN ssh_keys sk ON sk.id = m.ssh_key_id WHERE m.host_id = ?", (host_id,), ) - ssh_keys = [{"id": k[0], "label": k[1]} for k in await keys_cursor.fetchall()] + ssh_keys = [{"id": k[0], "label": k[1], "username": k[2]} for k in await keys_cursor.fetchall()] rdp_row = await (await conn.execute( - "SELECT updated_at FROM rdp_credentials WHERE host_id = ?", (host_id,) + "SELECT updated_at, username, domain FROM rdp_credentials WHERE host_id = ?", (host_id,) )).fetchone() return { "host_id": host_id, "ssh_keys": ssh_keys, "rdp_credentials_set": rdp_row is not None, "rdp_credentials_updated_at": rdp_row[0] if rdp_row else None, + "rdp_credentials_username": rdp_row[1] if rdp_row else None, + "rdp_credentials_domain": rdp_row[2] if rdp_row else None, } @@ -1034,7 +1051,7 @@ async def list_rdp_credentials(admin: CurrentUser = Depends(require_admin_or_sco scope = _scope(admin) tenant_filter, params = scope.sql_filter("hg.tenant_id") cursor = await conn.execute( - "SELECT h.id, h.hostname, h.address, hg.name, rc.updated_at " + "SELECT h.id, h.hostname, h.address, hg.name, rc.updated_at, rc.username, rc.domain " "FROM hosts h JOIN host_groups hg ON hg.id = h.host_group_id " "LEFT JOIN rdp_credentials rc ON rc.host_id = h.id " f"WHERE h.protocol = 'rdp'{tenant_filter} ORDER BY h.hostname", @@ -1045,6 +1062,7 @@ async def list_rdp_credentials(admin: CurrentUser = Depends(require_admin_or_sco { "host_id": r[0], "hostname": r[1], "address": r[2], "host_group_name": r[3], "credentials_set": r[4] is not None, "updated_at": r[4], + "username": r[5], "domain": r[6], } for r in rows ] @@ -1274,9 +1292,9 @@ async def create_ssh_key( ) cursor = await conn.execute( "INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type, " - "tenant_id, passphrase_enc) VALUES (?, ?, ?, ?, ?, ?, ?)", + "tenant_id, passphrase_enc, username) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", (payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type, - tenant_id, passphrase_enc), + tenant_id, passphrase_enc, (payload.username or "").strip() or None), ) new_id = cursor.lastrowid await write_audit_event( @@ -1284,6 +1302,9 @@ async def create_ssh_key( details={ "id": new_id, "label": payload.label, "key_type": payload.key_type, "has_passphrase": bool(payload.passphrase), + # Der Anmeldename ist kein Geheimnis und im Audit-Log ausdruecklich + # erwuenscht: er beantwortet "wer hat sich womit angemeldet". + "username": (payload.username or "").strip() or None, }, ) await conn.commit() @@ -1297,7 +1318,7 @@ async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh tenant_filter, params = scope.sql_filter("sk.tenant_id") cursor = await conn.execute( "SELECT sk.id, sk.label, sk.owner_user_id, sk.key_type, sk.created_at, sk.rotated_at, " - "sk.expires_at, sk.tenant_id, t.name, (sk.passphrase_enc IS NOT NULL) " + "sk.expires_at, sk.tenant_id, t.name, (sk.passphrase_enc IS NOT NULL), sk.username " "FROM ssh_keys sk JOIN tenants t ON t.id = sk.tenant_id " f"WHERE 1=1{tenant_filter} ORDER BY sk.id", params, @@ -1311,6 +1332,9 @@ async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh # Nur die Tatsache, NIE die Passphrase selbst -- kein Endpunkt # dieser Anwendung gibt jemals Klartext-Geheimnisse zurueck. "has_passphrase": bool(r[9]), + # Anmeldename des Zielsystems (Migration 0010). Kein Geheimnis -- + # er wird angezeigt, damit erkennbar ist, welcher Zugang das ist. + "username": r[10], } for r in rows ] @@ -1371,6 +1395,10 @@ async def update_ssh_key( fields.append("label = ?"); values.append(payload.label) if "owner_user_id" in payload.model_fields_set: fields.append("owner_user_id = ?"); values.append(payload.owner_user_id) + if "username" in payload.model_fields_set: + # Gleiche Semantik wie bei passphrase: Weglassen = unveraendert, + # explizites null/"" = entfernen. + fields.append("username = ?"); values.append((payload.username or "").strip() or None) if rotating: encrypted = encrypt_secret(payload.private_key_pem.encode(), associated_data=b"ssh_private_key") fields += ["private_key_enc = ?", "public_key = ?", "key_type = ?", @@ -1396,6 +1424,7 @@ async def update_ssh_key( "id": key_id, "rotated": rotating, "passphrase_changed": bool(rotating or passphrase_given), "has_passphrase": bool(passphrase), + "username_changed": "username" in payload.model_fields_set, }, ) await conn.commit() diff --git a/app/db/migrations/0010_credential_usernames.sql b/app/db/migrations/0010_credential_usernames.sql new file mode 100644 index 0000000..9c0d2a9 --- /dev/null +++ b/app/db/migrations/0010_credential_usernames.sql @@ -0,0 +1,55 @@ +-- Phase 12: Benutzernamen gehoeren zu den ZUGANGSDATEN, nicht zum Host. +-- +-- Bisher standen ssh_username bzw. rdp_username/rdp_domain in der Tabelle +-- hosts -- also beim "Server". Fachlich falsch: der Benutzername ist Teil der +-- Anmeldung (er gehoert zum Schluessel bzw. zum Passwort), nicht zur +-- Beschreibung des Zielsystems. Ab jetzt: +-- * SSH: ssh_keys.username (ein Benutzername je Schluessel) +-- * RDP: rdp_credentials.username / .domain (je Host-Passwortsatz) +-- Die alten hosts-Spalten bleiben im Schema (SQLite-Spalten zu entfernen +-- erzwingt einen Tabellen-Rebuild, siehe die ausfuehrliche Begruendung in +-- 0006_tenants.sql) und werden nur noch als Fallback fuer Datensaetze +-- gelesen, die vor dieser Migration angelegt und noch nicht umgestellt +-- wurden. Neue Schreibpfade fassen sie nicht mehr an, die Admin-Oberflaeche +-- zeigt sie nicht mehr an. + +ALTER TABLE ssh_keys ADD COLUMN username TEXT; +ALTER TABLE rdp_credentials ADD COLUMN username TEXT; +ALTER TABLE rdp_credentials ADD COLUMN domain TEXT; + +-- Uebernahme der bestehenden Werte: +-- +-- SSH: nur wenn ALLE Hosts, denen der Schluessel zugeordnet ist, denselben +-- Benutzernamen tragen (COUNT(DISTINCT ...) = 1). Waeren es mehrere, waere +-- jede automatische Wahl geraten -- solche Schluessel bleiben leer und +-- greifen weiter auf den Host-Fallback zurueck, bis ein Admin den +-- Benutzernamen am Schluessel setzt (die Oberflaeche weist darauf hin). +UPDATE ssh_keys + SET username = ( + SELECT h.ssh_username + FROM host_ssh_key_map m JOIN hosts h ON h.id = m.host_id + WHERE m.ssh_key_id = ssh_keys.id + AND h.ssh_username IS NOT NULL AND TRIM(h.ssh_username) <> '' + ORDER BY h.id LIMIT 1) + WHERE username IS NULL + AND (SELECT COUNT(DISTINCT h.ssh_username) + FROM host_ssh_key_map m JOIN hosts h ON h.id = m.host_id + WHERE m.ssh_key_id = ssh_keys.id + AND h.ssh_username IS NOT NULL AND TRIM(h.ssh_username) <> '') = 1; + +-- RDP: eindeutig, weil rdp_credentials ohnehin genau einen Datensatz pro Host +-- haelt (host_id ist Primaerschluessel). +UPDATE rdp_credentials + SET username = (SELECT h.rdp_username FROM hosts h WHERE h.id = rdp_credentials.host_id), + domain = (SELECT h.rdp_domain FROM hosts h WHERE h.id = rdp_credentials.host_id) + WHERE username IS NULL; + +-- hosts.ssh_host_key: der VOLLSTAENDIGE Host-Key im OpenSSH-Format, nicht nur +-- sein Fingerprint. Grund: mit dem Fingerprint allein laesst sich eine +-- Verbindung nicht vorab pinnen -- man sieht den Schluessel erst, wenn der +-- Server ihn praesentiert hat. Mit dem gespeicherten Schluessel kann der +-- Jumphost den vom Ziel angebotenen Schluessel VOR der Anmeldung byteweise +-- vergleichen (siehe app/ssh_proxy/proxy.py). Wird beim "Host-Key ermitteln" +-- mitgeschrieben und fuer Altbestand beim naechsten erfolgreichen +-- Verbindungsaufbau nachgetragen. +ALTER TABLE hosts ADD COLUMN ssh_host_key TEXT; diff --git a/app/main.py b/app/main.py index caafe11..8f6bac9 100644 --- a/app/main.py +++ b/app/main.py @@ -21,10 +21,31 @@ from app.catalog.routes import router as catalog_router from app.db import close_db, init_db from app.rdp_proxy.ws_tunnel import router as rdp_ws_router from app.security import log_stream +from app.ssh_proxy.proxy import bcrypt_kdf_available from app.ssh_proxy.sftp import router as sftp_router from app.ssh_proxy.terminal_ws import router as ssh_ws_router logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +logger = logging.getLogger("jumphost.main") + + +def _check_optional_dependencies() -> None: + """Meldet fehlende, formal optionale Laufzeitabhaengigkeiten beim Start. + + Bislang fiel das Fehlen von 'bcrypt' erst auf, wenn ein Benutzer eine + SSH-Sitzung startete -- und dann als englische asyncssh-Meldung, die nach + einem defekten Schluessel aussah. Die Warnung laeuft absichtlich NACH + log_stream.install(), damit sie auch im Live-Verbindungslog des + Adminbereichs sichtbar ist. + """ + if not bcrypt_kdf_available(): + logger.error( + "Das Python-Modul 'bcrypt' fehlt im Virtualenv. Passphrasegeschuetzte " + "SSH-Schluessel im OpenSSH-Format (ssh-keygen-Standard) koennen damit " + "NICHT geladen werden. Behebung: pip install -r requirements.txt " + "(bzw. Ansible-Rolle python_runtime erneut ausrollen) und Dienst " + "neu starten." + ) @asynccontextmanager @@ -33,6 +54,7 @@ async def lifespan(app: FastAPI): # app/security/log_stream.py) -- MUSS vor init_db() installiert werden, # damit auch fruehe Startmeldungen im Puffer landen. log_stream.install() + _check_optional_dependencies() await init_db() yield await close_db() diff --git a/app/models/schemas.py b/app/models/schemas.py index 7c3924d..9ddf7f3 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -114,9 +114,15 @@ class HostCreateRequest(BaseModel): port: int = Field(gt=0, le=65535) os_type: Literal["linux", "windows"] ssh_host_key_fingerprint: str | None = Field(default=None, max_length=512) - ssh_username: str | None = Field(default=None, max_length=128) - rdp_username: str | None = Field(default=None, max_length=128) - rdp_domain: str | None = Field(default=None, max_length=128) + # ---- Altlast (vor Migration 0010) ------------------------------------- + # Benutzername und Domaene gehoeren zu den ZUGANGSDATEN (ssh_keys.username + # bzw. rdp_credentials.username/.domain) und nicht zum Host. Die Felder + # bleiben nur entgegennahmefaehig, damit bestehende Skripte/Tokens nicht + # brechen; die Admin-Oberflaeche sendet sie nicht mehr, und der + # Verbindungsaufbau liest sie nur noch als Fallback. + ssh_username: str | None = Field(default=None, max_length=128, deprecated=True) + rdp_username: str | None = Field(default=None, max_length=128, deprecated=True) + rdp_domain: str | None = Field(default=None, max_length=128, deprecated=True) rdp_require_nla: bool = True clipboard_enabled: bool = True file_transfer_enabled: bool = True @@ -139,9 +145,10 @@ class HostUpdateRequest(BaseModel): hostname: str | None = Field(default=None, min_length=1, max_length=128) address: str | None = Field(default=None, min_length=1, max_length=255) port: int | None = Field(default=None, gt=0, le=65535) - ssh_username: str | None = None - rdp_username: str | None = None - rdp_domain: str | None = None + # Siehe HostCreateRequest: Altlast, gehoert zu den Zugangsdaten. + ssh_username: str | None = Field(default=None, deprecated=True) + rdp_username: str | None = Field(default=None, deprecated=True) + rdp_domain: str | None = Field(default=None, deprecated=True) rdp_require_nla: bool | None = None clipboard_enabled: bool | None = None file_transfer_enabled: bool | None = None @@ -214,6 +221,10 @@ class SshKeyCreateRequest(BaseModel): public_key: str = Field(min_length=1, max_length=8192) key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"] tenant_id: int | None = None + # Anmeldename am Zielsystem. Gehoert zum Schluessel, nicht zum Host + # (Migration 0010): ein Schluessel ist immer "der Zugang von Benutzer X". + # Fuer mehrere Konten wird je ein Schluesseleintrag angelegt. + username: str | None = Field(default=None, max_length=128) # Passphrase eines verschluesselten Private Keys. Wird -- wie das # Schluesselmaterial selbst -- mit dem KEK verschluesselt gespeichert # (ssh_keys.passphrase_enc, Migration 0009) und beim Verbindungsaufbau an @@ -230,6 +241,10 @@ class SshKeyUpdateRequest(BaseModel): private_key_pem: str | None = Field(default=None, min_length=1, max_length=32_768) public_key: str | None = Field(default=None, min_length=1, max_length=8192) key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"] | None = None + # Wird -- wie passphrase -- nur beachtet, wenn das Feld im Request + # vorkommt: Weglassen laesst den Benutzernamen unveraendert, explizites + # null/"" entfernt ihn. + username: str | None = Field(default=None, max_length=128) # Wird nur beachtet, wenn das Feld im Request vorkommt (siehe # update_ssh_key in app/admin/routes.py): explizites null/"" entfernt eine # hinterlegte Passphrase, Weglassen laesst sie unveraendert. Ohne @@ -244,3 +259,7 @@ class ConnectRequest(BaseModel): class RdpCredentialsRequest(BaseModel): password: str = Field(min_length=1, max_length=512) + # Benutzername und (optional) Domaene der Windows-Anmeldung. Seit + # Migration 0010 Teil der Zugangsdaten statt des Hosts. + username: str | None = Field(default=None, max_length=128) + domain: str | None = Field(default=None, max_length=128) diff --git a/app/rdp_proxy/guacd_client.py b/app/rdp_proxy/guacd_client.py index 6c49998..c27fe41 100644 --- a/app/rdp_proxy/guacd_client.py +++ b/app/rdp_proxy/guacd_client.py @@ -162,7 +162,13 @@ async def open_tunnel( return GuacdTunnel(reader, writer, connection_id) -def build_rdp_params(host: dict, password: str) -> dict[str, str]: +def build_rdp_params( + host: dict, + password: str, + *, + username: str | None = None, + domain: str | None = None, +) -> dict[str, str]: """Baut die Parameter-Map fuer die connect-Instruktion aus dem Host-Datensatz. Sicherheitsdefaults (siehe Konzept 6.3/6.7): NLA wird erzwungen sofern @@ -171,17 +177,19 @@ def build_rdp_params(host: dict, password: str) -> dict[str, str]: ohne interne PKI praesentiert jedes Windows-Ziel ein selbstsigniertes Zertifikat, guacd/FreeRDP bricht dann bereits vor dem ersten Bild ab. + `username`/`domain` kommen aus rdp_credentials (Migration 0010) -- der + Benutzername gehoert zur Anmeldung, nicht zum Zielsystem. Fehlen sie, wird + auf hosts.rdp_username/rdp_domain zurueckgegriffen (Altbestand). + WICHTIG: `host` muss aus app.ssh_proxy.proxy.load_host() stammen. Diese - Funktion liest rdp_username/rdp_domain/rdp_require_nla/clipboard_enabled/ - rdp_ignore_cert -- genau diese Spalten hat load_host() frueher NICHT - selektiert, wodurch hier still ein leerer Benutzername in die - connect-Instruktion ging und die Anmeldung am Zielsystem zwangslaeufig - scheiterte. Fehlt eine der Spalten jetzt trotzdem, ist das ein - Programmierfehler und soll auffallen statt still zu einer kaputten - Verbindung zu fuehren. + Funktion liest rdp_require_nla/clipboard_enabled/rdp_ignore_cert -- genau + diese Spalten hat load_host() frueher NICHT selektiert, wodurch hier still + eine unbrauchbare connect-Instruktion entstand. Fehlt eine der Spalten + jetzt trotzdem, ist das ein Programmierfehler und soll auffallen statt + still zu einer kaputten Verbindung zu fuehren. """ missing = [ - key for key in ("rdp_username", "rdp_require_nla", "clipboard_enabled", "rdp_ignore_cert") + key for key in ("rdp_require_nla", "clipboard_enabled", "rdp_ignore_cert") if key not in host ] if missing: @@ -189,20 +197,25 @@ def build_rdp_params(host: dict, password: str) -> dict[str, str]: f"Hostdatensatz unvollstaendig, fehlende Felder: {', '.join(missing)}" ) - username = (host.get("rdp_username") or "").strip() - if not username: + # Benutzername/Domaene kommen seit Migration 0010 aus den Zugangsdaten + # (rdp_credentials). Der Wert am Host wird nur noch als Fallback fuer + # Datensaetze gelesen, die vor der Migration angelegt wurden. + effective_username = (username or "").strip() or (host.get("rdp_username") or "").strip() + if not effective_username: raise GuacamoleProtocolError( - "Fuer diesen Host ist kein RDP-Benutzername hinterlegt " - "(Adminbereich -> Server -> RDP-Benutzer)." + "Fuer diesen Host ist kein RDP-Benutzername hinterlegt. Der Benutzername " + "gehoert zu den Zugangsdaten (Adminbereich -> Zugangsdaten -> " + "RDP/Windows-Passwoerter)." ) + effective_domain = (domain or "").strip() or (host.get("rdp_domain") or "").strip() clipboard_enabled = bool(host.get("clipboard_enabled")) return { "hostname": host["address"], "port": str(host["port"]), - "username": username, + "username": effective_username, "password": password, - "domain": host.get("rdp_domain") or "", + "domain": effective_domain, "security": "nla" if host.get("rdp_require_nla", True) else "any", "ignore-cert": "true" if host.get("rdp_ignore_cert", True) else "false", "disable-audio": "true", diff --git a/app/rdp_proxy/ws_tunnel.py b/app/rdp_proxy/ws_tunnel.py index 52a40af..f556c6a 100644 --- a/app/rdp_proxy/ws_tunnel.py +++ b/app/rdp_proxy/ws_tunnel.py @@ -174,8 +174,12 @@ async def rdp_tunnel( ) return + # username/domain stehen seit Migration 0010 bei den Zugangsdaten und nicht + # mehr am Host -- der Benutzername gehoert zur Anmeldung, nicht zur + # Beschreibung des Zielsystems. cred_cursor = await conn.execute( - "SELECT password_enc FROM rdp_credentials WHERE host_id = ?", (host_id,) + "SELECT password_enc, username, domain FROM rdp_credentials WHERE host_id = ?", + (host_id,), ) cred_row = await cred_cursor.fetchone() if cred_row is None: @@ -221,7 +225,9 @@ async def rdp_tunnel( # riss ein Fehler hier VOR jeglicher Protokollierung durch und die # Sitzung verschwand spurlos (schwarzer Bildschirm, kein Log-Eintrag). password = decrypt_secret(cred_row[0], associated_data=b"rdp_password") - params = build_rdp_params(host, password.decode()) + params = build_rdp_params( + host, password.decode(), username=cred_row[1], domain=cred_row[2] + ) tunnel = await open_tunnel( guacd_host=settings.guacd_host, guacd_port=settings.guacd_port, protocol="rdp", params=params, screen_width=width, screen_height=height, dpi=dpi, diff --git a/app/ssh_proxy/proxy.py b/app/ssh_proxy/proxy.py index 1038f00..2119f8a 100644 --- a/app/ssh_proxy/proxy.py +++ b/app/ssh_proxy/proxy.py @@ -44,6 +44,23 @@ class HostKeyDiscoveryError(Exception): super().__init__(f"Host-Key-Ermittlung fuer Host {host_id} fehlgeschlagen: {reason}") +class HostKeyNotPinnedError(Exception): + """Fuer den Host ist kein Host-Key hinterlegt. + + Bewusst ein harter Abbruch statt Trust-on-First-Use: ohne gepinnten + Schluessel laesst sich nicht feststellen, ob am anderen Ende das + erwartete System antwortet (Konzept 4.2/6.4). + """ + + def __init__(self, host_id: int) -> None: + self.host_id = host_id + super().__init__( + "Fuer diesen Host ist kein SSH-Host-Key hinterlegt. Im Adminbereich unter " + "'Server' -> Host-Key ermitteln einmalig uebernehmen (bewusste " + "Vertrauensentscheidung), danach ist die Verbindung moeglich." + ) + + class PrivateKeyUnusableError(Exception): """Der hinterlegte private Schluessel laesst sich nicht laden. @@ -62,6 +79,35 @@ class PrivateKeyUnusableError(Exception): super().__init__(message) +BCRYPT_MISSING_MESSAGE = ( + "Der private Schluessel ist passphrasegeschuetzt im OpenSSH-Format, auf dem " + "Server fehlt aber das Python-Modul 'bcrypt' -- asyncssh kann die " + "Schluesseldatei deshalb gar nicht erst entschluesseln. Weder der Schluessel " + "noch die hinterlegte Passphrase sind schuld. Abhilfe: 'bcrypt' im " + "Virtualenv der Anwendung nachinstallieren (pip install -r requirements.txt " + "bzw. Ansible-Rolle python_runtime erneut ausrollen) und den Dienst " + "jumphost-app neu starten." +) + + +def bcrypt_kdf_available() -> bool: + """Ist das (formal optionale) bcrypt-Modul mit KDF-Unterstuetzung da? + + asyncssh und cryptography benoetigen es fuer die bcrypt_pbkdf-Ableitung, + mit der OpenSSH verschluesselte Schluesseldateien schuetzt -- also fuer + genau das Format, das 'ssh-keygen' standardmaessig erzeugt. Fehlt das + Modul, scheitert JEDER passphrasegeschuetzte Schluessel mit + "OpenSSH private key encryption requires bcrypt with KDF support", obwohl + Schluessel und Passphrase korrekt sind. Es stand bis Phase 11 nicht in + requirements.txt und fehlte deshalb im ausgerollten Virtualenv. + """ + try: + import bcrypt # noqa: PLC0415 - bewusst lazy, das Modul ist optional + except ImportError: + return False + return hasattr(bcrypt, "kdf") + + def import_private_key_material( pem: bytes | str, passphrase: bytes | str | None = None ) -> asyncssh.SSHKey: @@ -96,6 +142,18 @@ def import_private_key_material( # wird hier korrekterweise nicht mitgefangen. text = str(exc) + if "bcrypt" in text.lower(): + # Umgebungsfehler, kein Benutzerfehler: ohne bcrypt kann asyncssh + # OpenSSH-verschluesselte Schluessel grundsaetzlich nicht lesen. + # Ohne diese Uebersetzung landete die englische asyncssh-Meldung + # ungefiltert im Terminalfenster und sah nach einem kaputten + # Schluessel aus. + logger.error( + "Import eines privaten Schluessels fehlgeschlagen: bcrypt fehlt im " + "Virtualenv (asyncssh-Meldung: %s)", text, + ) + raise PrivateKeyUnusableError(BCRYPT_MISSING_MESSAGE) from exc + if "Passphrase must be specified" in text: raise PrivateKeyUnusableError( "Der private Schluessel ist passphrasegeschuetzt, es ist aber keine " @@ -123,9 +181,41 @@ def import_private_key_material( ) from exc +#: Fehler, die den Aufbau einer SSH-Sitzung verhindern und dem angemeldeten +#: Benutzer im Klartext gezeigt werden duerfen (keine Geheimnisse, nur +#: Konfigurations-/Erreichbarkeitsaussagen). Wird von terminal_ws.py und +#: sftp.py gemeinsam benutzt, damit beide Wege dieselbe Meldung zeigen. +SSH_SETUP_ERRORS: tuple[type[Exception], ...] = ( + HostNotConfiguredError, + HostKeyNotPinnedError, + HostKeyMismatchError, + HostKeyDiscoveryError, + PrivateKeyUnusableError, +) + + +def describe_connection_error(exc: Exception) -> str: + """Deutsche Klartextmeldung zu einem Verbindungsfehler.""" + if isinstance(exc, HostKeyMismatchError): + return ( + "Der SSH-Host-Key des Ziels weicht vom hinterlegten ab -- die Verbindung wurde " + "abgebrochen, bevor Zugangsdaten gesendet wurden. " + f"Erwartet: {exc.expected}; angeboten: {exc.observed}. Wurde das Zielsystem neu " + "aufgesetzt, den Host-Key im Adminbereich erneut ermitteln." + ) + if isinstance(exc, HostKeyDiscoveryError): + return f"Das Ziel ist nicht erreichbar: {exc.reason}" + return str(exc) + + class _PinnedHostKeyClient(asyncssh.SSHClient): - """Erzwingt Strict Host Key Checking gegen einen fest hinterlegten - SHA-256-Fingerprint. Kein automatisches Trust-on-First-Use (TOFU).""" + """Zweite Verteidigungslinie fuer Strict Host Key Checking. + + ACHTUNG: asyncssh ruft validate_host_public_key() nur auf, wenn + known_hosts NICHT None ist -- diese Klasse allein hat das Pinning also + nicht durchgesetzt (siehe _verified_host_key(), das die eigentliche + Pruefung vor der Anmeldung uebernimmt). Sie bleibt erhalten, weil sie in + Konfigurationen mit gesetztem known_hosts greift und nie schadet.""" def __init__(self, expected_fingerprint: str | None, *, discovery_mode: bool = False) -> None: self.expected_fingerprint = expected_fingerprint @@ -147,7 +237,8 @@ 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 " + "rdp_username, rdp_domain, rdp_require_nla, clipboard_enabled, rdp_ignore_cert, " + "ssh_host_key " "FROM hosts WHERE id = ? AND is_active = 1", (host_id,), ) @@ -166,6 +257,11 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict: # dauerhaft gesperrt, unabhaengig von der Hostkonfiguration). "rdp_username", "rdp_domain", "rdp_require_nla", "clipboard_enabled", "rdp_ignore_cert", + # Vollstaendiger Host-Key (Migration 0010): erlaubt es, den vom Ziel + # angebotenen Schluessel VOR der Anmeldung byteweise zu vergleichen -- + # mit einem Fingerprint allein geht das nicht, den sieht man erst, + # wenn der Server seinen Schluessel bereits praesentiert hat. + "ssh_host_key", ) host = dict(zip(keys, row)) for flag in ("file_transfer_enabled", "rdp_require_nla", "clipboard_enabled", "rdp_ignore_cert"): @@ -173,17 +269,20 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict: 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. +async def load_ssh_credential_for_host( + conn: aiosqlite.Connection, host_id: int +) -> tuple[asyncssh.SSHKey, str | None]: + """Laedt den dem Host zugeordneten Schluessel, entschluesselt ihn und gibt + ihn zusammen mit dem am Schluessel hinterlegten Benutzernamen zurueck. - 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. + Der Benutzername gehoert seit Migration 0010 zu den Zugangsdaten + (ssh_keys.username) und nicht mehr zum Host: er ist Teil der Anmeldung, + nicht der Beschreibung des Zielsystems. Ist zu dem Schluessel eine + Passphrase hinterlegt (ssh_keys.passphrase_enc, Migration 0009), wird sie + ebenfalls entschluesselt und an asyncssh uebergeben. """ cursor = await conn.execute( - "SELECT sk.private_key_enc, sk.passphrase_enc FROM ssh_keys sk " + "SELECT sk.private_key_enc, sk.passphrase_enc, sk.username 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,), @@ -195,8 +294,9 @@ async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> passphrase = ( decrypt_secret(row[1], associated_data=b"ssh_key_passphrase") if row[1] else None ) + username = (row[2] or "").strip() or None try: - return import_private_key_material(pem, passphrase) + return import_private_key_material(pem, passphrase), username finally: # Bestpraxis: Referenzen auf das Klartextmaterial so schnell wie # moeglich loslassen (Konzept 6.4). @@ -204,78 +304,173 @@ async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> del passphrase +async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey: + """Rueckwaertskompatible Fassung ohne Benutzername (siehe + load_ssh_credential_for_host).""" + key, _username = await load_ssh_credential_for_host(conn, host_id) + return key + + +def resolve_ssh_username(host: dict, credential_username: str | None) -> str: + """Ermittelt den Anmeldenamen fuer eine SSH-Sitzung. + + Vorrang hat der Benutzername der Zugangsdaten (ssh_keys.username). Der + alte Wert am Host (hosts.ssh_username) wird nur noch als Fallback fuer + Datensaetze gelesen, die vor Migration 0010 angelegt wurden -- neue + Schreibpfade und die Oberflaeche pflegen ihn nicht mehr. + """ + username = (credential_username or "").strip() or (host.get("ssh_username") or "").strip() + if not username: + raise HostNotConfiguredError( + "Fuer diesen Host ist kein SSH-Benutzername hinterlegt. Der Benutzername " + "gehoert zum SSH-Key (Adminbereich -> Zugangsdaten -> SSH-Key bearbeiten)." + ) + return username + + +async def _verified_host_key( + conn: aiosqlite.Connection, host: dict +) -> asyncssh.SSHKey: + """Holt den Host-Key des Ziels OHNE Anmeldung und prueft ihn gegen den + hinterlegten Schluessel bzw. Fingerprint. + + Warum ueberhaupt ein eigener Schritt: asyncssh ruft + SSHClient.validate_host_public_key() NUR auf, wenn known_hosts nicht None + ist -- mit known_hosts=None ist die Host-Key-Pruefung komplett abgeschaltet + und der Callback wird nie erreicht. Genau diese Kombination war hier im + Einsatz, das Pinning also faktisch wirkungslos (aufgefallen, weil aus dem + gleichen Grund auch die Host-Key-Ermittlung keinen Fingerprint mehr sah). + asyncssh.get_server_host_key() fuehrt nur den Key-Exchange durch und + liefert den Schluessel zurueck -- damit laesst sich VOR jeder Anmeldung + entscheiden, ob das Gegenueber das erwartete System ist. + """ + expected_fingerprint = (host.get("ssh_host_key_fingerprint") or "").strip() + if not expected_fingerprint: + raise HostKeyNotPinnedError(host["id"]) + + try: + observed_key = await asyncssh.get_server_host_key( + host["address"], port=host["port"], connect_timeout=10 + ) + except (asyncssh.Error, OSError) as exc: + raise HostKeyDiscoveryError(host["id"], str(exc)) from exc + if observed_key is None: + raise HostKeyDiscoveryError(host["id"], "Das Ziel hat keinen Host-Key gesendet") + + observed_fingerprint = observed_key.get_fingerprint("sha256") + if observed_fingerprint != expected_fingerprint: + raise HostKeyMismatchError(expected_fingerprint, observed_fingerprint) + + stored = (host.get("ssh_host_key") or "").strip() + observed_pub = observed_key.export_public_key("openssh").decode().strip() + if stored: + # Schaerfer als der Fingerprintvergleich: byteweise Gleichheit des + # gesamten Schluessels. + if _public_key_body(stored) != _public_key_body(observed_pub): + raise HostKeyMismatchError(expected_fingerprint, observed_fingerprint) + else: + # Altbestand (vor Migration 0010 ermittelt): Schluessel nachtragen, + # nachdem er gegen den gepinnten Fingerprint geprueft wurde. + await conn.execute( + "UPDATE hosts SET ssh_host_key = ? WHERE id = ?", (observed_pub, host["id"]) + ) + await conn.commit() + return observed_key + + +def _public_key_body(openssh_line: str) -> tuple[str, str]: + """(Algorithmus, Base64-Material) einer OpenSSH-Zeile -- ohne Kommentar, + der sich zwischen Exporten unterscheiden darf.""" + parts = openssh_line.split() + if len(parts) < 2: + return ("", openssh_line.strip()) + return (parts[0], parts[1]) + + 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) + # Reihenfolge mit Absicht: erst das Ziel identifizieren, dann erst + # Schluesselmaterial entschluesseln und eine Anmeldung versuchen. + trusted_key = await _verified_host_key(conn, host) + private_key, credential_username = await load_ssh_credential_for_host(conn, host_id) + username = resolve_ssh_username(host, credential_username) client_factory = lambda: _PinnedHostKeyClient(host["ssh_host_key_fingerprint"]) try: connection = await asyncssh.connect( host["address"], port=host["port"], - username=host["ssh_username"], + username=username, client_keys=[private_key], - known_hosts=None, # Validierung erfolgt ausschliesslich ueber validate_host_public_key + known_hosts=None, client_factory=client_factory, connect_timeout=10, ) except asyncssh.Error as exc: logger.warning("SSH-Verbindungsfehler zu Host %s: %s", host_id, exc) raise + + # Zweite Haelfte des Pinnings: der Schluessel dieser Sitzung muss derselbe + # sein wie der eben gepruefte. Faengt den (sehr schmalen) Fall ab, dass + # zwischen Pruefung und Sitzungsaufbau ein anderes System antwortet. + session_key = connection.get_server_host_key() + session_fingerprint = session_key.get_fingerprint("sha256") if session_key else None + if session_fingerprint != trusted_key.get_fingerprint("sha256"): + connection.abort() + logger.error( + "Host-Key-Wechsel zwischen Pruefung und Sitzungsaufbau bei Host %s", host_id + ) + raise HostKeyMismatchError(host["ssh_host_key_fingerprint"], session_fingerprint) 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). + """Holt den Host-Key des Ziels EINMALIG und hinterlegt ihn (Fingerprint und + vollstaendigen Schluessel) in der DB. 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).""" + Bugfix (Meldung "Ziel nicht erreichbar: Permission denied for user ..."): + Die Ermittlung lief bisher ueber einen vollstaendigen + asyncssh.connect()-Versuch und las den Fingerprint aus dem Callback + SSHClient.validate_host_public_key(). Diesen Callback ruft asyncssh aber + NUR auf, wenn known_hosts nicht None ist -- mit known_hosts=None ist die + Host-Key-Pruefung abgeschaltet und der Callback wird nie erreicht. Der + Fingerprint blieb deshalb leer, und der voellig erwartbare Auth-Fehler der + anschliessenden Anmeldung ("Permission denied") wurde als "Ziel nicht + erreichbar" gemeldet, obwohl der Key-Exchange laengst erfolgreich war. + Jetzt wird asyncssh.get_server_host_key() verwendet: das fuehrt nur den + Key-Exchange durch, versucht gar keine Anmeldung und liefert den + Schluessel direkt zurueck. Ein Auth-Fehler kann die Ermittlung damit + nicht mehr verhindern.""" 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, + key = await asyncssh.get_server_host_key( + host["address"], port=host["port"], 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() + logger.warning("Host-Key-Ermittlung fuer Host %s fehlgeschlagen: %s", host_id, exc) + raise HostKeyDiscoveryError(host_id, str(exc)) from exc + if key is None: + raise HostKeyDiscoveryError(host_id, "Das Ziel hat keinen Host-Key gesendet") - fingerprint = client.observed_fingerprint + fingerprint = key.get_fingerprint("sha256") + public_key = key.export_public_key("openssh").decode().strip() await conn.execute( - "UPDATE hosts SET ssh_host_key_fingerprint = ? WHERE id = ?", (fingerprint, host_id) + "UPDATE hosts SET ssh_host_key_fingerprint = ?, ssh_host_key = ? WHERE id = ?", + (fingerprint, public_key, host_id), ) await conn.commit() + logger.info( + "Host-Key fuer Host %s durch Admin %s uebernommen: %s", host_id, admin_user_id, fingerprint + ) return fingerprint diff --git a/app/ssh_proxy/sftp.py b/app/ssh_proxy/sftp.py index d830ecd..8f0dcca 100644 --- a/app/ssh_proxy/sftp.py +++ b/app/ssh_proxy/sftp.py @@ -17,9 +17,11 @@ from app.rbac import user_has_role_for_host from app.security.audit import write_audit_event from app.security.av_scan import scan_bytes from app.ssh_proxy.proxy import ( + SSH_SETUP_ERRORS, HostNotConfiguredError, PrivateKeyUnusableError, connect_to_host, + describe_connection_error, load_host, ) @@ -102,15 +104,13 @@ async def upload_file( await remote_file.write(data) finally: ssh_conn.close() - except HostNotConfiguredError as exc: - raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) - except PrivateKeyUnusableError as exc: - # Gleiche Ursache wie bei einer scheiternden Terminalsitzung (siehe - # app/ssh_proxy/terminal_ws.py): passphrasegeschuetzter Schluessel - # ohne hinterlegte Passphrase. Hier als 400 mit Klartext statt als - # unbehandelter 500. + except SSH_SETUP_ERRORS as exc: + # Alles, was den Verbindungsaufbau verhindert und in der Konfiguration + # begruendet ist (fehlender Benutzername/Schluessel, unbrauchbarer + # Schluessel, nicht gepinnter oder abweichender Host-Key, Ziel nicht + # erreichbar): 400 mit Klartext statt eines unbehandelten 500. logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc) - raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) + raise HTTPException(status.HTTP_400_BAD_REQUEST, describe_connection_error(exc)) await _log_transfer( conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="upload", @@ -139,15 +139,13 @@ async def download_file( data = await remote_file.read() finally: ssh_conn.close() - except HostNotConfiguredError as exc: - raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) - except PrivateKeyUnusableError as exc: - # Gleiche Ursache wie bei einer scheiternden Terminalsitzung (siehe - # app/ssh_proxy/terminal_ws.py): passphrasegeschuetzter Schluessel - # ohne hinterlegte Passphrase. Hier als 400 mit Klartext statt als - # unbehandelter 500. + except SSH_SETUP_ERRORS as exc: + # Alles, was den Verbindungsaufbau verhindert und in der Konfiguration + # begruendet ist (fehlender Benutzername/Schluessel, unbrauchbarer + # Schluessel, nicht gepinnter oder abweichender Host-Key, Ziel nicht + # erreichbar): 400 mit Klartext statt eines unbehandelten 500. logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc) - raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) + raise HTTPException(status.HTTP_400_BAD_REQUEST, describe_connection_error(exc)) sha256 = hashlib.sha256(data).hexdigest() filename = remote_path.rsplit("/", 1)[-1] diff --git a/app/ssh_proxy/terminal_ws.py b/app/ssh_proxy/terminal_ws.py index d881ed3..2a12afc 100644 --- a/app/ssh_proxy/terminal_ws.py +++ b/app/ssh_proxy/terminal_ws.py @@ -24,9 +24,11 @@ from app.recordings.recorder import SessionRecorder from app.security import active_sessions from app.security.audit import write_audit_event from app.ssh_proxy.proxy import ( + SSH_SETUP_ERRORS, HostNotConfiguredError, PrivateKeyUnusableError, connect_to_host, + describe_connection_error, load_host, ) @@ -153,11 +155,18 @@ async def ssh_terminal(websocket: WebSocket, host_id: int): await websocket.send_json({"type": "error", "message": str(exc)}) except Exception: logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True) - except HostNotConfiguredError as exc: + except SSH_SETUP_ERRORS as exc: + # Alles, was den Sitzungsaufbau verhindert und der Benutzer selbst + # einordnen kann: fehlender Benutzername/Schluessel, nicht gepinnter + # oder abweichender Host-Key, unerreichbares Ziel. Bewusst mit + # Klartextmeldung statt "interner Fehler" -- der Grund steht in der + # Konfiguration, nicht im Code. logger.warning("SSH-Sitzung %s abgebrochen: %s", session_id, exc) end_reason = "error" try: - await websocket.send_json({"type": "error", "message": str(exc)}) + await websocket.send_json( + {"type": "error", "message": describe_connection_error(exc)} + ) except Exception: logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True) except asyncio.CancelledError: diff --git a/requirements.txt b/requirements.txt index 97a1d18..19032c0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,12 @@ starlette==1.6.0 uvicorn[standard]==0.32.1 aiosqlite==0.20.0 asyncssh==2.18.0 +# Pflicht, nicht optional: asyncssh (und cryptography) koennen ohne dieses +# Modul KEINEN passphrasegeschuetzten Schluessel im OpenSSH-Format lesen -- +# also genau das, was `ssh-keygen` standardmaessig erzeugt. Fehlt es, endet +# jeder Import mit "OpenSSH private key encryption requires bcrypt with KDF +# support". Entspricht dem Extra asyncssh[bcrypt]. +bcrypt==4.3.0 argon2-cffi==23.1.0 pyotp==2.9.0 qrcode[pil]==7.4.2 diff --git a/static/js/admin.js b/static/js/admin.js index db8b117..c9264d0 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -505,6 +505,27 @@ box.replaceChildren(el("div", { className: `banner ${type === "error" ? "error" : "ok"}`, textContent: message })); } + function describeEffectiveUsername(host, isRdp) { + if (isRdp) { + if (host.rdp_credentials_username) { + const domain = host.rdp_credentials_domain ? `, Domaene ${host.rdp_credentials_domain}` : ""; + return `Anmeldung als "${host.rdp_credentials_username}"${domain} (aus den RDP-Zugangsdaten unten).`; + } + if (host.rdp_username) { + return `Noch kein Benutzername in den Zugangsdaten -- es wird der alte Wert am Host verwendet ("${host.rdp_username}"). Bitte unten bei den RDP-Zugangsdaten neu setzen.`; + } + return "Kein Windows-Benutzername hinterlegt -- unten bei den RDP-Zugangsdaten eintragen, sonst schlaegt die Anmeldung fehl."; + } + const named = (host.ssh_keys || []).filter((k) => k.username); + if (named.length) { + return `Anmeldung als ${named.map((k) => `"${k.username}" (Key ${k.label})`).join(", ")}.`; + } + if (host.ssh_username) { + return `Noch kein Benutzername am SSH-Key -- es wird der alte Wert am Host verwendet ("${host.ssh_username}"). Bitte im Reiter "Zugangsdaten" am Schluessel eintragen.`; + } + return 'Kein SSH-Benutzername hinterlegt -- im Reiter "Zugangsdaten" am zugeordneten Schluessel eintragen, sonst kommt keine Verbindung zustande.'; + } + async function showHostDetail(hostId) { setDetailBanner("", "ok"); try { @@ -516,9 +537,6 @@ document.getElementById("he-hostname").value = host.hostname; document.getElementById("he-address").value = host.address; document.getElementById("he-port").value = String(host.port); - document.getElementById("he-ssh-username").value = host.ssh_username || ""; - document.getElementById("he-rdp-username").value = host.rdp_username || ""; - document.getElementById("he-rdp-domain").value = host.rdp_domain || ""; document.getElementById("he-clipboard").checked = host.clipboard_enabled; document.getElementById("he-filetransfer").checked = host.file_transfer_enabled; document.getElementById("he-nla").checked = host.rdp_require_nla; @@ -526,9 +544,11 @@ document.getElementById("he-active").checked = host.is_active; const isRdp = host.protocol === "rdp"; - document.getElementById("he-ssh-username-box").classList.toggle("hidden", isRdp); - document.getElementById("he-rdp-username-box").classList.toggle("hidden", !isRdp); - document.getElementById("he-rdp-domain-box").classList.toggle("hidden", !isRdp); + // Der Anmeldename steht bei den Zugangsdaten (SSH-Key bzw. + // RDP-Passwortsatz). Hier wird nur angezeigt, welcher Name greifen + // wuerde -- inklusive des Altbestands am Host, solange er noch benutzt + // wird. + document.getElementById("he-username-hint").textContent = describeEffectiveUsername(host, isRdp); document.getElementById("he-nla-box").classList.toggle("hidden", !isRdp); document.getElementById("he-ignorecert-box").classList.toggle("hidden", !isRdp); document.getElementById("host-detail-ssh").classList.toggle("hidden", isRdp); @@ -548,6 +568,7 @@ el("tr", {}, [ el("td", { textContent: String(k.id) }), el("td", { textContent: k.label }), + el("td", { textContent: k.username || "(kein Benutzername)" }), el("td", {}, [ actionButton("Entfernen", "btn-danger", async () => { await apiFetch(`/admin/hosts/${host.id}/ssh-keys/${k.id}`, { method: "DELETE" }); @@ -561,8 +582,10 @@ keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`)); document.getElementById("rdp-cred-status").textContent = host.rdp_credentials_set - ? `RDP-Passwort ist gesetzt (zuletzt aktualisiert: ${host.rdp_credentials_updated_at}).` - : "Noch kein RDP-Passwort gesetzt."; + ? `RDP-Zugangsdaten gesetzt fuer "${host.rdp_credentials_username || "(kein Benutzername)"}" (zuletzt aktualisiert: ${host.rdp_credentials_updated_at}).` + : "Noch keine RDP-Zugangsdaten gesetzt."; + document.getElementById("hrc-username").value = host.rdp_credentials_username || host.rdp_username || ""; + document.getElementById("hrc-domain").value = host.rdp_credentials_domain || host.rdp_domain || ""; } catch (err) { setDetailBanner(err.message, "error"); } @@ -575,9 +598,6 @@ hostname: document.getElementById("he-hostname").value.trim(), address: document.getElementById("he-address").value.trim(), port: Number(document.getElementById("he-port").value), - ssh_username: document.getElementById("he-ssh-username").value.trim() || null, - rdp_username: document.getElementById("he-rdp-username").value.trim() || null, - rdp_domain: document.getElementById("he-rdp-domain").value.trim() || null, clipboard_enabled: document.getElementById("he-clipboard").checked, file_transfer_enabled: document.getElementById("he-filetransfer").checked, rdp_require_nla: document.getElementById("he-nla").checked, @@ -594,9 +614,6 @@ document.getElementById("hc-protocol").addEventListener("change", (ev) => { const isRdp = ev.target.value === "rdp"; - document.getElementById("hc-ssh-username-box").classList.toggle("hidden", isRdp); - document.getElementById("hc-rdp-username-box").classList.toggle("hidden", !isRdp); - document.getElementById("hc-rdp-domain-box").classList.toggle("hidden", !isRdp); document.getElementById("hc-nla-box").classList.toggle("hidden", !isRdp); document.getElementById("hc-ignorecert-box").classList.toggle("hidden", !isRdp); document.getElementById("hc-ignorecert-hint").classList.toggle("hidden", !isRdp); @@ -632,9 +649,6 @@ protocol, port: Number(document.getElementById("hc-port").value), os_type: document.getElementById("hc-os-type").value, - ssh_username: document.getElementById("hc-ssh-username").value.trim() || null, - rdp_username: document.getElementById("hc-rdp-username").value.trim() || null, - rdp_domain: document.getElementById("hc-rdp-domain").value.trim() || null, rdp_require_nla: document.getElementById("hc-nla").checked, rdp_ignore_cert: document.getElementById("hc-ignorecert").checked, clipboard_enabled: document.getElementById("hc-clipboard").checked, @@ -679,7 +693,9 @@ ev.preventDefault(); try { const password = document.getElementById("hrc-password").value; - await sendJson(`/admin/hosts/${activeHostId}/rdp-credentials`, "PUT", { password }); + const username = document.getElementById("hrc-username").value.trim(); + const domain = document.getElementById("hrc-domain").value.trim() || null; + await sendJson(`/admin/hosts/${activeHostId}/rdp-credentials`, "PUT", { password, username, domain }); showBanner("RDP-Zugangsdaten gespeichert.", "ok"); ev.target.reset(); await showHostDetail(activeHostId); @@ -709,6 +725,7 @@ el("tr", {}, [ el("td", { textContent: String(k.id) }), el("td", { textContent: k.label }), + el("td", { textContent: k.username || "-" }), el("td", { textContent: k.key_type }), el("td", { textContent: k.owner_user_id === null ? "-" : String(k.owner_user_id) }), el("td", { textContent: k.tenant_name }), @@ -736,6 +753,7 @@ document.getElementById("ssh-key-edit-panel").classList.remove("hidden"); document.getElementById("ssh-key-edit-name").textContent = `${k.label} (#${k.id})`; document.getElementById("ske-label").value = k.label; + document.getElementById("ske-username").value = k.username || ""; document.getElementById("ske-owner").value = k.owner_user_id === null ? "" : String(k.owner_user_id); document.getElementById("skr-type").value = k.key_type; document.getElementById("skp-passphrase").value = ""; @@ -784,6 +802,9 @@ const ownerRaw = document.getElementById("ske-owner").value.trim(); await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", { label: document.getElementById("ske-label").value.trim() || null, + // Leeres Feld -> null entfernt den Benutzernamen bewusst (die + // Server-Semantik unterscheidet zwischen "Feld fehlt" und "null"). + username: document.getElementById("ske-username").value.trim() || null, owner_user_id: ownerRaw ? Number(ownerRaw) : null, }); showBanner("SSH-Key aktualisiert.", "ok"); @@ -817,6 +838,7 @@ const tenantSelect = document.getElementById("skc-tenant"); const payload = { label: document.getElementById("skc-label").value.trim(), + username: document.getElementById("skc-username").value.trim() || null, owner_user_id: ownerRaw ? Number(ownerRaw) : null, key_type: document.getElementById("skc-type").value, private_key_pem: document.getElementById("skc-private").value, @@ -844,6 +866,7 @@ el("td", { textContent: r.hostname }), el("td", { textContent: r.address }), el("td", { textContent: r.host_group_name }), + el("td", { textContent: r.username || "-" }), el("td", {}, [el("span", { className: `badge ${r.credentials_set ? "ok" : ""}`, textContent: r.credentials_set ? "gesetzt" : "nicht gesetzt" })]), el("td", { textContent: r.updated_at || "-" }), el("td", {}, [ diff --git a/templates/admin.html b/templates/admin.html index e7b19eb..71bf3fe 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -235,18 +235,7 @@ -
- - -
- - +

Der Benutzername wird nicht mehr am Server gepflegt, sondern bei den Zugangsdaten: bei SSH am SSH-Key, bei RDP/Windows zusammen mit dem Passwort (Reiter "Zugangsdaten").

@@ -286,18 +275,7 @@ -
- - -
- - +

@@ -308,7 +286,7 @@

SSH-Host-Key

-

Ermittelt den Host-Key EINMALIG ohne Pruefung (bewusste Trust-Entscheidung) und speichert den Fingerprint fuer kuenftiges Pinning. Wird prominent im Audit-Log vermerkt.

+

Holt den Host-Key des Ziels EINMALIG ohne Anmeldung (reiner Key-Exchange) und speichert Fingerprint und Schluessel fuer das Pinning kuenftiger Verbindungen. Bewusste Vertrauensentscheidung, wird prominent im Audit-Log vermerkt. Ohne hinterlegten Host-Key wird jede SSH-Verbindung zu diesem Host abgelehnt.

@@ -331,10 +309,19 @@

RDP-Zugangsdaten

+
+ + +
+
+ + +
+

Benutzername, Domaene und Passwort gehoeren zusammen und werden gemeinsam gespeichert. Das Passwort wird AES-256-GCM-verschluesselt abgelegt und nie wieder ausgegeben; beim Aendern bitte alle drei Felder ausfuellen.

@@ -350,6 +337,11 @@ +
+ + +

Der Anmeldename, mit dem dieser Schluessel benutzt wird (z.B. root oder l4u). Er gehoert zum Zugang, nicht zum Server -- fuer ein zweites Konto einen zweiten Schluesseleintrag anlegen.

+
@@ -385,7 +377,7 @@

Vorhandene SSH-Keys

- +
IDLabelTypBesitzerMandantPassphraseErstelltRotiert
IDLabelBenutzerTypBesitzerMandantPassphraseErstelltRotiert
@@ -398,11 +390,15 @@
+
+ + +
- +

Passphrase

@@ -448,7 +444,7 @@

Uebersicht aller RDP-Hosts. Passwoerter werden AES-256-GCM-verschluesselt gespeichert und sind nach dem Setzen nicht mehr im Klartext abrufbar.

- +
HostAdresseHostgruppeStatusZuletzt gesetzt
HostAdresseHostgruppeBenutzerStatusZuletzt gesetzt
diff --git a/tests/test_phase11.py b/tests/test_phase11.py new file mode 100644 index 0000000..09a196d --- /dev/null +++ b/tests/test_phase11.py @@ -0,0 +1,167 @@ +""" +Tests fuer Phase 11: fehlendes bcrypt-Modul im Virtualenv. + +Symptom im Betrieb: "Der private Schluessel konnte nicht gelesen werden: +OpenSSH private key encryption requires bcrypt with KDF support". Der +Schluessel und die (seit Phase 10 speicherbare) Passphrase waren dabei +korrekt -- es fehlte schlicht das Modul 'bcrypt', ohne das asyncssh die +bcrypt_pbkdf-Ableitung von OpenSSH nicht durchfuehren kann. 'bcrypt' stand +nicht in requirements.txt und war deshalb im ausgerollten Virtualenv nicht +vorhanden. + +Geprueft wird hier: + * die Abhaengigkeit ist deklariert (sonst kommt der Fehler nach dem + naechsten Redeploy wieder), + * die englische asyncssh-Meldung wird in einen deutschen Klartext + uebersetzt, der die Ursache (Serverumgebung, nicht Schluessel/Passphrase) + benennt, + * beim Anwendungsstart wird gewarnt, statt den Fehler bis zur ersten + Benutzersitzung zu verschleppen, + * mit installiertem bcrypt laesst sich ein echter, OpenSSH-verschluesselter + Schluessel tatsaechlich importieren (End-to-End-Gegenprobe). +""" +from __future__ import annotations + +import logging +import pathlib + +import pytest + +from app.ssh_proxy.proxy import ( + BCRYPT_MISSING_MESSAGE, + PrivateKeyUnusableError, + bcrypt_kdf_available, + import_private_key_material, +) + +# Wortlaut von asyncssh (asyncssh/public_key.py) bzw. cryptography. +ASYNCSSH_MESSAGE = "OpenSSH private key encryption requires bcrypt with KDF support" +CRYPTOGRAPHY_MESSAGE = "Need bcrypt module" + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent + + +def test_bcrypt_ist_in_requirements_deklariert(): + """Kernursache: ohne diesen Eintrag fehlt bcrypt nach jedem Redeploy.""" + requirements = (REPO_ROOT / "requirements.txt").read_text(encoding="utf-8") + zeilen = [ + z.strip() for z in requirements.splitlines() + if z.strip() and not z.strip().startswith("#") + ] + assert any(z.split("==")[0].split("[")[0].strip() == "bcrypt" for z in zeilen), ( + "bcrypt muss in requirements.txt stehen -- asyncssh kann sonst keinen " + "passphrasegeschuetzten OpenSSH-Schluessel lesen." + ) + + +@pytest.mark.parametrize("meldung", [ASYNCSSH_MESSAGE, CRYPTOGRAPHY_MESSAGE]) +@pytest.mark.parametrize("passphrase", [None, "Testpassphrase-2026"]) +def test_fehlendes_bcrypt_wird_uebersetzt(monkeypatch, meldung, passphrase): + """Die englische Bibliotheksmeldung darf nicht mehr durchschlagen.""" + import asyncssh + + aufrufe = [] + + def _fake_import(pem, pw=None): + aufrufe.append(pw) + raise ValueError(meldung) + + monkeypatch.setattr(asyncssh, "import_private_key", _fake_import) + + with pytest.raises(PrivateKeyUnusableError) as excinfo: + import_private_key_material("egal", passphrase) + + text = str(excinfo.value) + assert text == BCRYPT_MISSING_MESSAGE + assert "bcrypt" in text + assert "requirements.txt" in text + # Der Benutzer soll NICHT bei Schluessel/Passphrase suchen: + assert "Passphrase" in text and "nicht" in text + assert isinstance(excinfo.value.__cause__, ValueError) + # Kein zweiter Importversuch ohne Passphrase: die Ursache liegt in der + # Umgebung, ein Retry wuerde nur dieselbe Meldung erzeugen. + assert len(aufrufe) == 1 + + +def test_andere_importfehler_bleiben_unveraendert(monkeypatch): + """Gegenprobe: die Phase-10-Uebersetzungen duerfen nicht kapern.""" + import asyncssh + + def _fake_import(pem, pw=None): + raise ValueError("Passphrase must be specified to import encrypted private keys") + + monkeypatch.setattr(asyncssh, "import_private_key", _fake_import) + + with pytest.raises(PrivateKeyUnusableError) as excinfo: + import_private_key_material("egal", None) + assert "keine Passphrase hinterlegt" in str(excinfo.value) + assert str(excinfo.value) != BCRYPT_MISSING_MESSAGE + + +def test_bcrypt_kdf_available_erkennt_modul_ohne_kdf(monkeypatch): + """bcrypt < 3.1.3 bringt kein kdf() mit -- das zaehlt als 'nicht da'.""" + import sys + import types + + attrappe = types.ModuleType("bcrypt") + monkeypatch.setitem(sys.modules, "bcrypt", attrappe) + assert bcrypt_kdf_available() is False + + attrappe.kdf = lambda **_: b"" + assert bcrypt_kdf_available() is True + + +def test_bcrypt_kdf_available_bei_fehlendem_modul(monkeypatch): + import builtins + + original = builtins.__import__ + + def _import(name, *args, **kwargs): + if name == "bcrypt": + raise ImportError("No module named 'bcrypt'") + return original(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _import) + assert bcrypt_kdf_available() is False + + +def test_startup_warnt_bei_fehlendem_bcrypt(monkeypatch, caplog): + """Der Fehler soll beim Start auffallen, nicht erst in der Sitzung.""" + import app.main as main + + monkeypatch.setattr(main, "bcrypt_kdf_available", lambda: False) + with caplog.at_level(logging.ERROR, logger="jumphost.main"): + main._check_optional_dependencies() + assert any("bcrypt" in r.message for r in caplog.records) + + caplog.clear() + monkeypatch.setattr(main, "bcrypt_kdf_available", lambda: True) + with caplog.at_level(logging.ERROR, logger="jumphost.main"): + main._check_optional_dependencies() + assert caplog.records == [] + + +def test_echter_openssh_schluessel_mit_passphrase_ist_importierbar(): + """End-to-End-Gegenprobe mit installiertem bcrypt. + + Erzeugt genau das Format, an dem der Import gescheitert ist (OpenSSH, + bcrypt_pbkdf-verschluesselt), und laedt es ueber den regulaeren Codepfad. + """ + pytest.importorskip("bcrypt", reason="ohne bcrypt ist genau dieser Pfad kaputt") + + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ed25519 + + passphrase = "Testpassphrase-2026" + pem = ed25519.Ed25519PrivateKey.generate().private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.OpenSSH, + serialization.BestAvailableEncryption(passphrase.encode()), + ).decode() + assert "OPENSSH PRIVATE KEY" in pem + + schluessel = import_private_key_material(pem, passphrase) + assert schluessel is not None + + with pytest.raises(PrivateKeyUnusableError): + import_private_key_material(pem, None) diff --git a/tests/test_phase12.py b/tests/test_phase12.py new file mode 100644 index 0000000..c9bb69e --- /dev/null +++ b/tests/test_phase12.py @@ -0,0 +1,427 @@ +""" +Tests fuer Phase 12: + + 1) "Host-Key konnte nicht ermittelt werden -- Ziel nicht erreichbar: + Permission denied for user ... on host ...": die Ermittlung lief ueber + einen vollstaendigen asyncssh.connect()-Versuch und wartete darauf, dass + SSHClient.validate_host_public_key() den Fingerprint liefert. Diesen + Callback ruft asyncssh aber nur auf, wenn known_hosts NICHT None ist -- + mit known_hosts=None ist die Host-Key-Pruefung abgeschaltet. Der + Fingerprint blieb leer, und der voellig erwartbare Auth-Fehler wurde als + "Ziel nicht erreichbar" gemeldet. Jetzt: asyncssh.get_server_host_key(), + also reiner Key-Exchange ohne Anmeldung. + 2) Aus derselben Ursache folgt: das Pinning im REGULAEREN Verbindungspfad + war wirkungslos -- der Callback wurde dort ebenso nie aufgerufen. Die + Pruefung findet jetzt vor der Anmeldung statt und wird nach dem + Sitzungsaufbau noch einmal gegengeprueft. + 3) Der Benutzername gehoert zu den ZUGANGSDATEN, nicht zum Host + (Migration 0010): ssh_keys.username bzw. rdp_credentials.username/.domain. + +Die Tests kommen ohne laufenden SSH-Server aus: asyncssh wird an den beiden +Eintrittspunkten (get_server_host_key/connect) ersetzt, die Datenbank ist eine +echte SQLite-Datei, auf die die echten Migrationen angewendet werden. +""" +from __future__ import annotations + +import pathlib +import sqlite3 + +import pytest + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 + +from app.rdp_proxy.guacd_client import GuacamoleProtocolError, build_rdp_params +from app.security.crypto import encrypt_secret +from app.ssh_proxy.proxy import ( + HostKeyDiscoveryError, + HostKeyMismatchError, + HostKeyNotPinnedError, + HostNotConfiguredError, + connect_to_host, + discover_and_store_host_key, + load_host, + resolve_ssh_username, +) + +MIGRATIONS = pathlib.Path(__file__).resolve().parent.parent / "app" / "db" / "migrations" + +FINGERPRINT = "SHA256:0123456789abcdefghijklmnopqrstuvwxyzABCDEFG" +PUBLIC_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTKEYTESTKEYTESTKEYTESTKEYTESTKEY host" + + +# -------------------------------------------------------------------------- +# Hilfsmittel: echte SQLite-DB mit den echten Migrationen + winziger +# aiosqlite-kompatibler Adapter (die Produktionsfunktionen erwarten await). +# -------------------------------------------------------------------------- + +class _Cursor: + def __init__(self, cursor: sqlite3.Cursor) -> None: + self._cursor = cursor + self.lastrowid = cursor.lastrowid + + async def fetchone(self): + return self._cursor.fetchone() + + async def fetchall(self): + return self._cursor.fetchall() + + +class FakeConnection: + """Genau so viel aiosqlite, wie app/ssh_proxy/proxy.py benutzt.""" + + def __init__(self, path: str) -> None: + self.raw = sqlite3.connect(path) + + async def execute(self, sql, params=()): + return _Cursor(self.raw.execute(sql, params)) + + async def commit(self): + self.raw.commit() + + +def _apply_migrations(db: sqlite3.Connection, upto: str | None = None) -> None: + for path in sorted(MIGRATIONS.glob("*.sql")): + db.executescript(path.read_text(encoding="utf-8")) + if upto and path.name.startswith(upto): + break + db.commit() + + +def _plain_key_pem() -> str: + return ed25519.Ed25519PrivateKey.generate().private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.OpenSSH, + serialization.NoEncryption(), + ).decode() + + +def _make_db(tmp_path, *, host_username=None, key_username="l4u", fingerprint=FINGERPRINT, + host_key=PUBLIC_KEY, with_key=True) -> str: + path = str(tmp_path / "jumphost.sqlite3") + db = sqlite3.connect(path) + _apply_migrations(db) + db.execute("INSERT INTO host_groups (id, name, tenant_id) VALUES (1, 'gruppe', 1)") + db.execute( + "INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, " + "ssh_host_key_fingerprint, ssh_host_key, ssh_username) " + "VALUES (1, 1, 'ziel', '185.228.139.3', 'ssh', 22, 'linux', ?, ?, ?)", + (fingerprint, host_key, host_username), + ) + if with_key: + db.execute( + "INSERT INTO ssh_keys (id, label, private_key_enc, public_key, key_type, tenant_id, username) " + "VALUES (1, 'testkey', ?, 'ssh-ed25519 AAAA', 'ed25519', 1, ?)", + (encrypt_secret(_plain_key_pem().encode(), associated_data=b"ssh_private_key"), key_username), + ) + db.execute("INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (1, 1)") + db.commit() + db.close() + return path + + +class FakeHostKey: + def __init__(self, fingerprint=FINGERPRINT, public=PUBLIC_KEY) -> None: + self._fingerprint = fingerprint + self._public = public + + def get_fingerprint(self, kind="sha256"): + return self._fingerprint + + def export_public_key(self, fmt="openssh"): + return self._public.encode() + + +class FakeConnectionResult: + """Minimale asyncssh-Verbindung: liefert den Host-Key der Sitzung.""" + + def __init__(self, host_key=None) -> None: + self._host_key = host_key or FakeHostKey() + self.aborted = False + + def get_server_host_key(self): + return self._host_key + + def abort(self): + self.aborted = True + + def close(self): + pass + + +# -------------------------------------------------------------------------- +# 1) Host-Key-Ermittlung +# -------------------------------------------------------------------------- + +async def test_discovery_braucht_keine_anmeldung(tmp_path, monkeypatch): + """Reproduktion des gemeldeten Fehlers: die Anmeldung scheitert (der Host + kennt den Benutzer nicht), der Host-Key ist trotzdem ermittelbar.""" + import asyncssh + + async def _fake_connect(*a, **kw): # pragma: no cover - darf nicht laufen + raise AssertionError("Die Ermittlung darf keine Anmeldung mehr versuchen") + + async def _fake_get_host_key(host, port=22, **kw): + assert (host, port) == ("185.228.139.3", 22) + return FakeHostKey() + + monkeypatch.setattr(asyncssh, "connect", _fake_connect) + monkeypatch.setattr(asyncssh, "get_server_host_key", _fake_get_host_key, raising=False) + + conn = FakeConnection(_make_db(tmp_path, fingerprint=None, host_key=None)) + fingerprint = await discover_and_store_host_key(conn, 1, admin_user_id=1) + + assert fingerprint == FINGERPRINT + row = conn.raw.execute( + "SELECT ssh_host_key_fingerprint, ssh_host_key FROM hosts WHERE id = 1" + ).fetchone() + # Nicht nur der Fingerprint, auch der vollstaendige Schluessel wird + # gespeichert -- nur damit laesst sich spaeter VOR der Anmeldung pinnen. + assert row == (FINGERPRINT, PUBLIC_KEY) + + +async def test_discovery_meldet_echte_unerreichbarkeit(tmp_path, monkeypatch): + import asyncssh + + async def _fake_get_host_key(host, port=22, **kw): + raise OSError("Connection refused") + + monkeypatch.setattr(asyncssh, "get_server_host_key", _fake_get_host_key, raising=False) + conn = FakeConnection(_make_db(tmp_path)) + with pytest.raises(HostKeyDiscoveryError) as excinfo: + await discover_and_store_host_key(conn, 1, admin_user_id=1) + assert "Connection refused" in excinfo.value.reason + + +# -------------------------------------------------------------------------- +# 2) Pinning +# -------------------------------------------------------------------------- + +async def test_verbindung_ohne_hinterlegten_hostkey_wird_abgelehnt(tmp_path, monkeypatch): + import asyncssh + + async def _fake_connect(*a, **kw): # pragma: no cover - darf nicht laufen + raise AssertionError("Ohne gepinnten Host-Key darf nicht verbunden werden") + + monkeypatch.setattr(asyncssh, "connect", _fake_connect) + conn = FakeConnection(_make_db(tmp_path, fingerprint=None, host_key=None)) + with pytest.raises(HostKeyNotPinnedError): + await connect_to_host(conn, 1) + + +async def test_abweichender_hostkey_bricht_vor_der_anmeldung_ab(tmp_path, monkeypatch): + """Der entscheidende Punkt: kein einziges Byte Zugangsdaten geht raus.""" + import asyncssh + + versuche = [] + + async def _fake_connect(*a, **kw): # pragma: no cover - darf nicht laufen + versuche.append(kw) + raise AssertionError("Bei abweichendem Host-Key darf keine Anmeldung erfolgen") + + async def _fake_get_host_key(host, port=22, **kw): + return FakeHostKey(fingerprint="SHA256:einVoelligAndererSchluessel", public="ssh-ed25519 AAAAfremd x") + + monkeypatch.setattr(asyncssh, "connect", _fake_connect) + monkeypatch.setattr(asyncssh, "get_server_host_key", _fake_get_host_key, raising=False) + + conn = FakeConnection(_make_db(tmp_path)) + with pytest.raises(HostKeyMismatchError) as excinfo: + await connect_to_host(conn, 1) + assert excinfo.value.expected == FINGERPRINT + assert versuche == [] + + +async def test_verbindung_nutzt_benutzernamen_der_zugangsdaten(tmp_path, monkeypatch): + import asyncssh + + aufrufe = {} + + async def _fake_connect(address, **kw): + aufrufe["address"] = address + aufrufe.update(kw) + return FakeConnectionResult() + + async def _fake_get_host_key(host, port=22, **kw): + return FakeHostKey() + + monkeypatch.setattr(asyncssh, "connect", _fake_connect) + monkeypatch.setattr(asyncssh, "get_server_host_key", _fake_get_host_key, raising=False) + + conn = FakeConnection(_make_db(tmp_path, host_username="alterWertAmHost", key_username="l4u")) + await connect_to_host(conn, 1) + # Der Name aus den Zugangsdaten gewinnt gegen den Altwert am Host. + assert aufrufe["username"] == "l4u" + + +async def test_hostkey_wechsel_nach_der_pruefung_beendet_die_sitzung(tmp_path, monkeypatch): + import asyncssh + + verbindung = FakeConnectionResult(FakeHostKey(fingerprint="SHA256:plotzlichAnders")) + + async def _fake_connect(address, **kw): + return verbindung + + async def _fake_get_host_key(host, port=22, **kw): + return FakeHostKey() + + monkeypatch.setattr(asyncssh, "connect", _fake_connect) + monkeypatch.setattr(asyncssh, "get_server_host_key", _fake_get_host_key, raising=False) + + conn = FakeConnection(_make_db(tmp_path)) + with pytest.raises(HostKeyMismatchError): + await connect_to_host(conn, 1) + assert verbindung.aborted is True + + +async def test_altbestand_ohne_gespeicherten_hostkey_wird_nachgetragen(tmp_path, monkeypatch): + """Hosts, deren Key vor Migration 0010 ermittelt wurde, kennen nur den + Fingerprint. Der Schluessel wird beim naechsten Verbindungsaufbau + nachgetragen -- nachdem er gegen den Fingerprint geprueft wurde.""" + import asyncssh + + async def _fake_connect(address, **kw): + return FakeConnectionResult() + + async def _fake_get_host_key(host, port=22, **kw): + return FakeHostKey() + + monkeypatch.setattr(asyncssh, "connect", _fake_connect) + monkeypatch.setattr(asyncssh, "get_server_host_key", _fake_get_host_key, raising=False) + + conn = FakeConnection(_make_db(tmp_path, host_key=None)) + await connect_to_host(conn, 1) + (stored,) = conn.raw.execute("SELECT ssh_host_key FROM hosts WHERE id = 1").fetchone() + assert stored == PUBLIC_KEY + + +# -------------------------------------------------------------------------- +# 3) Benutzername gehoert zu den Zugangsdaten +# -------------------------------------------------------------------------- + +def test_resolve_ssh_username_vorrang_und_fallback(): + assert resolve_ssh_username({"ssh_username": "alt"}, "neu") == "neu" + # Altbestand: solange am Schluessel nichts steht, greift der Host-Wert. + assert resolve_ssh_username({"ssh_username": "alt"}, None) == "alt" + assert resolve_ssh_username({"ssh_username": "alt"}, " ") == "alt" + + +def test_resolve_ssh_username_ohne_jeden_wert_meldet_klartext(): + with pytest.raises(HostNotConfiguredError) as excinfo: + resolve_ssh_username({"ssh_username": None}, None) + text = str(excinfo.value) + assert "Benutzername" in text and "SSH-Key" in text + + +def _rdp_host(**overrides): + host = { + "id": 7, "address": "10.0.0.5", "port": 3389, "rdp_require_nla": True, + "clipboard_enabled": True, "rdp_ignore_cert": True, "file_transfer_enabled": True, + } + host.update(overrides) + return host + + +def test_rdp_params_nehmen_benutzernamen_der_zugangsdaten(): + params = build_rdp_params( + _rdp_host(rdp_username="alt", rdp_domain="ALTEDOMAENE"), + "geheim", username="Administrator", domain="CONTOSO", + ) + assert params["username"] == "Administrator" + assert params["domain"] == "CONTOSO" + assert params["password"] == "geheim" + + +def test_rdp_params_fallback_auf_altwert_am_host(): + params = build_rdp_params(_rdp_host(rdp_username="alt", rdp_domain="D"), "geheim") + assert (params["username"], params["domain"]) == ("alt", "D") + + +def test_rdp_params_ohne_benutzernamen_meldet_zugangsdaten(): + with pytest.raises(GuacamoleProtocolError) as excinfo: + build_rdp_params(_rdp_host(), "geheim") + assert "Zugangsdaten" in str(excinfo.value) + + +def test_rdp_params_brauchen_die_hostspalte_nicht_mehr(): + """Ein Hostdatensatz ohne rdp_username ist kein Fehler mehr -- der Name + kommt jetzt von woanders.""" + params = build_rdp_params(_rdp_host(), "geheim", username="svc") + assert params["username"] == "svc" + + +# -------------------------------------------------------------------------- +# 4) Migration 0010: Uebernahme der Altwerte +# -------------------------------------------------------------------------- + +def _pre_0010_db(tmp_path) -> sqlite3.Connection: + db = sqlite3.connect(str(tmp_path / "alt.sqlite3")) + _apply_migrations(db, upto="0009") + return db + + +def test_migration_uebernimmt_eindeutige_ssh_benutzernamen(tmp_path): + db = _pre_0010_db(tmp_path) + db.execute("INSERT INTO host_groups (id, name, tenant_id) VALUES (1, 'g', 1)") + for host_id in (1, 2): + db.execute( + "INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, ssh_username) " + "VALUES (?, 1, 'h', '10.0.0.1', 'ssh', 22, 'linux', 'l4u')", + (host_id,), + ) + db.execute( + "INSERT INTO ssh_keys (id, label, private_key_enc, public_key, key_type, tenant_id) " + "VALUES (1, 'k', X'00', 'pub', 'ed25519', 1)" + ) + db.execute("INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (1, 1), (2, 1)") + db.commit() + + db.executescript((MIGRATIONS / "0010_credential_usernames.sql").read_text(encoding="utf-8")) + (username,) = db.execute("SELECT username FROM ssh_keys WHERE id = 1").fetchone() + assert username == "l4u" + + +def test_migration_raet_nicht_bei_mehrdeutigen_benutzernamen(tmp_path): + """Zwei Hosts, zwei verschiedene Benutzernamen, ein Schluessel: hier waere + jede automatische Wahl geraten -- also bleibt das Feld leer und der + Fallback greift weiter.""" + db = _pre_0010_db(tmp_path) + db.execute("INSERT INTO host_groups (id, name, tenant_id) VALUES (1, 'g', 1)") + for host_id, name in ((1, "root"), (2, "l4u")): + db.execute( + "INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, ssh_username) " + "VALUES (?, 1, 'h', '10.0.0.1', 'ssh', 22, 'linux', ?)", + (host_id, name), + ) + db.execute( + "INSERT INTO ssh_keys (id, label, private_key_enc, public_key, key_type, tenant_id) " + "VALUES (1, 'k', X'00', 'pub', 'ed25519', 1)" + ) + db.execute("INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (1, 1), (2, 1)") + db.commit() + + db.executescript((MIGRATIONS / "0010_credential_usernames.sql").read_text(encoding="utf-8")) + (username,) = db.execute("SELECT username FROM ssh_keys WHERE id = 1").fetchone() + assert username is None + + +def test_migration_uebernimmt_rdp_benutzer_und_domaene(tmp_path): + db = _pre_0010_db(tmp_path) + db.execute("INSERT INTO host_groups (id, name, tenant_id) VALUES (1, 'g', 1)") + db.execute( + "INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, " + "rdp_username, rdp_domain) " + "VALUES (1, 1, 'win', '10.0.0.9', 'rdp', 3389, 'windows', 'Administrator', 'CONTOSO')" + ) + db.execute("INSERT INTO rdp_credentials (host_id, password_enc) VALUES (1, X'00')") + db.commit() + + db.executescript((MIGRATIONS / "0010_credential_usernames.sql").read_text(encoding="utf-8")) + row = db.execute("SELECT username, domain FROM rdp_credentials WHERE host_id = 1").fetchone() + assert row == ("Administrator", "CONTOSO") + + +async def test_load_host_liefert_den_hostkey_mit(tmp_path): + conn = FakeConnection(_make_db(tmp_path)) + host = await load_host(conn, 1) + assert host["ssh_host_key"] == PUBLIC_KEY diff --git a/tests/test_phase9.py b/tests/test_phase9.py index 00f8251..5499cf5 100644 --- a/tests/test_phase9.py +++ b/tests/test_phase9.py @@ -219,12 +219,18 @@ async def test_credentials_manage_role_grants_non_admin_write_access(client): assert resp.status_code == 200, resp.text assert resp.json()["rdp_credentials_set"] is False - resp = await client.put(f"/admin/hosts/{host_id}/rdp-credentials", json={"password": "s3hr-geheim!!"}) + # Benutzername gehoert seit Migration 0010 zu den Zugangsdaten und ist + # beim Setzen Pflicht (siehe tests/test_phase12.py). + resp = await client.put( + f"/admin/hosts/{host_id}/rdp-credentials", + json={"password": "s3hr-geheim!!", "username": "Administrator"}, + ) assert resp.status_code == 200, resp.text resp = await client.get(f"/admin/hosts/{host_id}/credentials") assert resp.status_code == 200, resp.text assert resp.json()["rdp_credentials_set"] is True + assert resp.json()["rdp_credentials_username"] == "Administrator" # Ein User OHNE diese Rolle bleibt weiterhin ausgesperrt. client.cookies.clear() diff --git a/tests/test_tenants.py b/tests/test_tenants.py index 160b0cd..e23f7d5 100644 --- a/tests/test_tenants.py +++ b/tests/test_tenants.py @@ -25,6 +25,25 @@ from __future__ import annotations import pyotp import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 + + +def _key_pem() -> str: + """Echtes, unverschluesseltes Schluesselmaterial. + + Seit Phase 10 prueft POST/PUT /admin/ssh-keys das Material sofort gegen + asyncssh (damit ein unbrauchbarer Schluessel nicht erst beim ersten + Verbindungsversuch auffaellt). Platzhalter wie "PEM" werden deshalb -- + voellig korrekt -- mit HTTP 400 abgelehnt; diese Tests hier pruefen aber + Mandantenisolation und CRUD und brauchen einen gueltigen Schluessel. + """ + return ed25519.Ed25519PrivateKey.generate().private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.OpenSSH, + serialization.NoEncryption(), + ).decode() + async def _create_user(conn, username: str, password: str, *, is_admin: bool = False, home_tenant_id=None) -> int: from app.security.passwords import hash_password @@ -244,7 +263,7 @@ async def test_tenant_admin_isolated_from_other_tenants_users_and_ssh_keys(clien resp = await client.post( "/admin/ssh-keys", json={ - "label": "std-key", "private_key_pem": "PEM", "public_key": "PUB", + "label": "std-key", "private_key_pem": _key_pem(), "public_key": "PUB", "key_type": "ed25519", "tenant_id": 1, }, ) @@ -273,7 +292,7 @@ async def test_tenant_admin_isolated_from_other_tenants_users_and_ssh_keys(clien # Eigenen SSH-Key anlegen -- ohne tenant_id automatisch auf E erzwungen. resp = await client.post( "/admin/ssh-keys", - json={"label": "e-key", "private_key_pem": "PEM2", "public_key": "PUB2", "key_type": "ed25519"}, + json={"label": "e-key", "private_key_pem": _key_pem(), "public_key": "PUB2", "key_type": "ed25519"}, ) assert resp.status_code == 201, resp.text e_key_id = resp.json()["id"] @@ -513,19 +532,20 @@ async def test_ssh_key_update_rotate_and_delete(client): resp = await client.post( "/admin/ssh-keys", - json={"label": "orig-key", "private_key_pem": "PEM", "public_key": "PUB", "key_type": "ed25519"}, + json={"label": "orig-key", "private_key_pem": _key_pem(), "public_key": "PUB", "key_type": "ed25519"}, ) key_id = resp.json()["id"] resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"label": "renamed-key"}) assert resp.status_code == 200, resp.text - # Rotation erfordert alle drei Felder gemeinsam. + # Rotation erfordert alle drei Felder gemeinsam (400 noch vor jeder + # Materialpruefung -- der Platzhalter hier ist deshalb Absicht). resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"private_key_pem": "NEWPEM"}) assert resp.status_code == 400, resp.text resp = await client.put( f"/admin/ssh-keys/{key_id}", - json={"private_key_pem": "NEWPEM", "public_key": "NEWPUB", "key_type": "rsa-4096"}, + json={"private_key_pem": _key_pem(), "public_key": "NEWPUB", "key_type": "rsa-4096"}, ) assert resp.status_code == 200, resp.text @@ -579,13 +599,16 @@ async def test_host_detail_endpoint_includes_ssh_keys_and_rdp_flag(client): resp = await client.post( "/admin/ssh-keys", - json={"label": "detail-key", "private_key_pem": "PEM", "public_key": "PUB", "key_type": "ed25519"}, + json={"label": "detail-key", "private_key_pem": _key_pem(), "public_key": "PUB", "key_type": "ed25519"}, ) key_id = resp.json()["id"] resp = await client.post(f"/admin/hosts/{host_id}/ssh-keys/{key_id}") assert resp.status_code == 200, resp.text - resp = await client.put(f"/admin/hosts/{host_id}/rdp-credentials", json={"password": "Correct-Horse-Battery-Staple-O2"}) + resp = await client.put( + f"/admin/hosts/{host_id}/rdp-credentials", + json={"password": "Correct-Horse-Battery-Staple-O2", "username": "Administrator"}, + ) assert resp.status_code == 200, resp.text resp = await client.get(f"/admin/hosts/{host_id}")