168 lines
6.1 KiB
Python
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)
|