Files
ssh-jumphost/tests/test_phase11.py
2026-08-21 06:14:10 +02:00

168 lines
6.1 KiB
Python

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