Files
ssh-jumphost/tests/test_phase10.py
2026-09-02 20:30:44 +02:00

344 lines
15 KiB
Python

"""
Tests fuer die in "Phase 10" behobenen Ursachen dafuer, dass weder SSH- noch
RDP-Sitzungen zustande kamen:
1) SSH: der hinterlegte private Schluessel war passphrasegeschuetzt.
asyncssh.import_private_key() wurde ohne Passphrase aufgerufen und warf
KeyImportError("Passphrase must be specified to import encrypted private
keys") -- ein ValueError, KEIN asyncssh.Error, der deshalb an allen
Fehlerbehandlungen vorbei aus der WS-Route hinauslief. Jetzt: Passphrase
wird KEK-verschluesselt mitgespeichert (Migration 0009), beim Laden
uebergeben und beim Anlegen/Rotieren/Nachtragen sofort geprueft (400).
2) RDP: load_host() selektierte rdp_username/rdp_domain/rdp_require_nla/
clipboard_enabled gar nicht -- build_rdp_params() baute daraus eine
connect-Instruktion ohne Benutzernamen. Zusaetzlich war ignore-cert hart
auf "false" verdrahtet (jetzt hosts.rdp_ignore_cert, Default an).
3) Guacamole-Protokoll: Laengenangaben zaehlen ZEICHEN, nicht Bytes -- ein
Umlaut (etwa im RDP-Passwort) verschob sonst den gesamten Datenstrom.
Die WebSocket-Routen selbst lassen sich mit diesem Testsetup (httpx
ASGITransport, kein WS-Support) nicht end-to-end fahren; geprueft werden hier
die Admin-Endpunkte, die DB-Schicht und die reine Protokoll-/Parameterlogik.
"""
from __future__ import annotations
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
PASSPHRASE = "Testpassphrase-2026"
def _plain_key_pem() -> str:
key = ed25519.Ed25519PrivateKey.generate()
return key.private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH,
serialization.NoEncryption(),
).decode()
def _encrypted_key_pem(passphrase: str = PASSPHRASE) -> str:
"""Passphrasegeschuetzter Schluessel.
Bevorzugt das OpenSSH-Format (das, was `ssh-keygen` erzeugt und was der
gemeldete Fehler betraf). Dessen Verschluesselung setzt das optionale
bcrypt-Modul voraus; fehlt es, wird auf PKCS#8 ausgewichen -- asyncssh
liest beide, und der hier gepruefte Codepfad (fehlende bzw. falsche
Passphrase) ist fuer beide identisch."""
from cryptography.exceptions import UnsupportedAlgorithm
key = ed25519.Ed25519PrivateKey.generate()
enc = serialization.BestAvailableEncryption(passphrase.encode())
try:
return key.private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, enc
).decode()
except UnsupportedAlgorithm: # pragma: no cover - haengt an der Umgebung
return key.private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, enc
).decode()
async def _create_user(conn, username: str, password: str, *, is_admin: bool = False) -> int:
from app.security.passwords import hash_password
cursor = await conn.execute(
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
"VALUES (?, ?, ?, 0)",
(username, hash_password(password), int(is_admin)),
)
await conn.commit()
return cursor.lastrowid
async def _login_full(client, username: str, password: str) -> str:
import pyotp
resp = await client.post("/auth/login", json={"username": username, "password": password})
assert resp.status_code == 200, resp.text
pending = resp.json()["pending_token"]
resp = await client.post("/auth/totp/enroll/start", json={"pending_token": pending})
assert resp.status_code == 200, resp.text
provisioning_uri = resp.json()["provisioning_uri"]
secret = dict(part.split("=") for part in provisioning_uri.split("?", 1)[1].split("&"))["secret"]
code = pyotp.TOTP(secret).now()
resp = await client.post("/auth/totp/enroll/confirm", json={"pending_token": pending, "code": code})
assert resp.status_code == 200, resp.text
return resp.cookies.get("jh_session")
# ---------------------------------------------------------------------------
# 1) SSH-Schluessel mit Passphrase
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_ssh_key_rejects_encrypted_key_without_passphrase(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "p10_admin1", "Correct-Horse-Battery-Staple-A1", is_admin=True)
await _login_full(client, "p10_admin1", "Correct-Horse-Battery-Staple-A1")
resp = await client.post("/admin/ssh-keys", json={
"label": "verschluesselt-ohne-passphrase",
"private_key_pem": _encrypted_key_pem(),
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 test",
"key_type": "ed25519",
})
# Vorher: 201 -- der Fehler fiel erst beim ersten Verbindungsversuch auf.
assert resp.status_code == 400, resp.text
assert "passphrasegeschuetzt" in resp.json()["detail"].lower()
@pytest.mark.asyncio
async def test_create_ssh_key_with_correct_passphrase_succeeds_and_is_stored_encrypted(client):
from app.db import get_db
from app.security.crypto import decrypt_secret
conn = get_db()
await _create_user(conn, "p10_admin2", "Correct-Horse-Battery-Staple-A2", is_admin=True)
await _login_full(client, "p10_admin2", "Correct-Horse-Battery-Staple-A2")
resp = await client.post("/admin/ssh-keys", json={
"label": "verschluesselt-mit-passphrase",
"private_key_pem": _encrypted_key_pem(),
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 test",
"key_type": "ed25519",
"passphrase": PASSPHRASE,
})
assert resp.status_code == 201, resp.text
key_id = resp.json()["id"]
row = await (await conn.execute(
"SELECT passphrase_enc FROM ssh_keys WHERE id = ?", (key_id,)
)).fetchone()
assert row[0] is not None, "Passphrase wurde nicht gespeichert"
assert PASSPHRASE.encode() not in row[0], "Passphrase liegt im Klartext in der DB"
assert decrypt_secret(row[0], associated_data=b"ssh_key_passphrase") == PASSPHRASE.encode()
# Die Liste verraet nur, DASS eine Passphrase hinterlegt ist.
listing = (await client.get("/admin/ssh-keys")).json()
entry = next(k for k in listing if k["id"] == key_id)
assert entry["has_passphrase"] is True
assert "passphrase" not in entry, "Endpunkt darf die Passphrase selbst nie zurueckgeben"
@pytest.mark.asyncio
async def test_wrong_passphrase_is_rejected_and_correct_one_can_be_added_later(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "p10_admin3", "Correct-Horse-Battery-Staple-A3", is_admin=True)
await _login_full(client, "p10_admin3", "Correct-Horse-Battery-Staple-A3")
pem = _encrypted_key_pem()
resp = await client.post("/admin/ssh-keys", json={
"label": "nachtragen", "private_key_pem": pem,
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 test",
"key_type": "ed25519", "passphrase": PASSPHRASE,
})
assert resp.status_code == 201, resp.text
key_id = resp.json()["id"]
# Falsche Passphrase nachtragen -> abgelehnt, nichts geaendert
resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"passphrase": "falsch"})
assert resp.status_code == 400, resp.text
# Korrekte Passphrase nachtragen -> akzeptiert
resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"passphrase": PASSPHRASE})
assert resp.status_code == 200, resp.text
# Ein Update ohne das Feld darf die Passphrase NICHT loeschen
resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"label": "umbenannt"})
assert resp.status_code == 200, resp.text
row = await (await conn.execute(
"SELECT passphrase_enc FROM ssh_keys WHERE id = ?", (key_id,)
)).fetchone()
assert row[0] is not None, "Label-Update hat die Passphrase geloescht"
@pytest.mark.asyncio
async def test_load_private_key_for_host_uses_stored_passphrase(client):
"""Der konkret gemeldete Fehlerfall, komplett ueber die DB-Schicht."""
from app.db import get_db
from app.ssh_proxy.proxy import PrivateKeyUnusableError, load_private_key_for_host
from app.security.crypto import encrypt_secret
conn = get_db()
from app.security.passwords import hash_password
cursor = await conn.execute(
"INSERT INTO users (username, password_hash) VALUES ('p10-user', ?)",
(hash_password("Correct-Horse-Battery-Staple-P10"),),
)
user_id = cursor.lastrowid
cursor = await conn.execute("INSERT INTO host_groups (name) VALUES ('p10-group')")
hg_id = cursor.lastrowid
cursor = await conn.execute(
"INSERT INTO hosts (host_group_id, hostname, address, protocol, port, os_type, ssh_username) "
"VALUES (?, 'p10-host', '10.10.0.1', 'ssh', 22, 'linux', 'root')", (hg_id,),
)
host_id = cursor.lastrowid
pem = _encrypted_key_pem()
cursor = await conn.execute(
"INSERT INTO ssh_keys (label, private_key_enc, public_key, key_type, passphrase_enc) "
"VALUES ('k', ?, 'pub', 'ed25519', ?)",
(encrypt_secret(pem.encode(), associated_data=b"ssh_private_key"),
encrypt_secret(PASSPHRASE.encode(), associated_data=b"ssh_key_passphrase")),
)
key_id = cursor.lastrowid
await conn.execute(
"INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (?, ?)", (host_id, key_id)
)
# Teil D Schritt 4 (Achse B): load_private_key_for_host() loest seither
# NICHT mehr blind ueber den Host auf, sondern nur noch fuer einen
# Benutzer, dessen Gruppe den Schluessel ueber group_ssh_key_grants
# freigegeben bekommen hat (siehe Docstring von
# load_ssh_key_credential_for_host in app/ssh_proxy/proxy.py).
cursor = await conn.execute("INSERT INTO user_groups (name) VALUES ('p10-team')")
group_id = cursor.lastrowid
await conn.execute(
"INSERT INTO user_group_members (user_group_id, user_id) VALUES (?, ?)", (group_id, user_id)
)
await conn.execute(
"INSERT INTO group_ssh_key_grants (user_group_id, ssh_key_id) VALUES (?, ?)", (group_id, key_id)
)
await conn.commit()
# Mit hinterlegter Passphrase laedt der Schluessel.
assert await load_private_key_for_host(conn, host_id, user_id=user_id) is not None
# Ohne sie: klare Meldung statt eines nach aussen durchschlagenden
# KeyImportError (das war der gemeldete Abbruch ohne Fehlermeldung).
await conn.execute("UPDATE ssh_keys SET passphrase_enc = NULL WHERE id = ?", (key_id,))
await conn.commit()
with pytest.raises(PrivateKeyUnusableError) as excinfo:
await load_private_key_for_host(conn, host_id, user_id=user_id)
assert "passphrasegeschuetzt" in str(excinfo.value).lower()
# ---------------------------------------------------------------------------
# 2) RDP-Verbindungsparameter
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_load_host_returns_rdp_columns(client):
"""load_host() muss genau die Felder liefern, die build_rdp_params() liest."""
from app.db import get_db
from app.ssh_proxy.proxy import load_host
conn = get_db()
cursor = await conn.execute("INSERT INTO host_groups (name) VALUES ('p10-rdp-group')")
hg_id = cursor.lastrowid
cursor = await conn.execute(
"INSERT INTO hosts (host_group_id, hostname, address, protocol, port, os_type, "
"rdp_username, rdp_domain) VALUES (?, 'win-ts01', '10.10.0.5', 'rdp', 3389, 'windows', "
"'Administrator', 'CORP')", (hg_id,),
)
await conn.commit()
host = await load_host(conn, cursor.lastrowid)
for field in ("rdp_username", "rdp_domain", "rdp_require_nla", "clipboard_enabled",
"rdp_ignore_cert"):
assert field in host, f"load_host() liefert '{field}' nicht"
assert host["rdp_username"] == "Administrator"
assert host["rdp_domain"] == "CORP"
assert host["rdp_ignore_cert"] is True # Default aus Migration 0009
def test_build_rdp_params_passes_username_and_cert_policy():
from app.rdp_proxy.guacd_client import GuacamoleProtocolError, build_rdp_params
host = {
"id": 1, "hostname": "win-ts01", "address": "10.10.0.5", "port": 3389,
"file_transfer_enabled": True, "rdp_username": "Administrator",
"rdp_domain": "CORP", "rdp_require_nla": True, "clipboard_enabled": True,
"rdp_ignore_cert": True,
}
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 2): build_rdp_params()
# verlangt inzwischen session_id als Pflicht-Keyword-Argument (echte
# Signaturerweiterung, app/rdp_proxy/guacd_client.py) -- fuer diese Tests
# ist der konkrete Wert irrelevant, ein beliebiger int reicht.
params = build_rdp_params(host, "geheim", session_id=1)
assert params["username"] == "Administrator"
assert params["domain"] == "CORP"
assert params["security"] == "nla"
assert params["ignore-cert"] == "true"
assert params["disable-copy"] == "false"
strict = build_rdp_params(
dict(host, rdp_ignore_cert=False, clipboard_enabled=False), "geheim", session_id=1,
)
assert strict["ignore-cert"] == "false"
assert strict["disable-copy"] == "true" and strict["disable-paste"] == "true"
# Kein Benutzername -> klare Meldung statt stiller Fehlanmeldung am Ziel
with pytest.raises(GuacamoleProtocolError):
build_rdp_params(dict(host, rdp_username=""), "geheim", session_id=1)
# Unvollstaendiger Hostdatensatz (der alte load_host()-Zustand)
with pytest.raises(GuacamoleProtocolError):
build_rdp_params(
{k: host[k] for k in ("id", "hostname", "address", "port", "file_transfer_enabled")},
"geheim", session_id=1,
)
# ---------------------------------------------------------------------------
# 3) Guacamole-Protokoll: Laengen zaehlen Zeichen, nicht Bytes
# ---------------------------------------------------------------------------
def test_instruction_length_counts_characters_not_bytes():
from app.rdp_proxy.guacd_client import encode_instruction, parse_instruction_text
# "Grüße" sind 5 Zeichen, aber 7 Bytes in UTF-8.
text = encode_instruction("clipboard", "Grüße")
assert text == "9.clipboard,5.Grüße;", text
assert parse_instruction_text(text) == ["clipboard", "Grüße"]
@pytest.mark.asyncio
async def test_read_instruction_reads_multibyte_characters():
import asyncio
from app.rdp_proxy.guacd_client import encode_instruction, read_instruction
reader = asyncio.StreamReader()
reader.feed_data(encode_instruction("name", "Bürö-Süd").encode("utf-8"))
reader.feed_eof()
assert await read_instruction(reader) == ["name", "Bürö-Süd"]
def test_internal_opcode_instruction_is_parseable():
"""guacamole-common-js sendet ping/UUID mit leerem Opcode ('0.,...')."""
from app.rdp_proxy.guacd_client import parse_instruction_text
from app.rdp_proxy.ws_tunnel import INTERNAL_DATA_OPCODE
parsed = parse_instruction_text("0.,4.ping,13.1755721410123;")
assert parsed[0] == INTERNAL_DATA_OPCODE
assert parsed[1] == "ping"