""" 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)