connect fix 3
This commit is contained in:
@ -66,10 +66,16 @@ from app.security.api_tokens import (
|
||||
validate_scopes,
|
||||
)
|
||||
from app.security.audit import verify_chain, write_audit_event
|
||||
from app.security.crypto import encrypt_secret
|
||||
from app.security.crypto import decrypt_secret, encrypt_secret
|
||||
from app.security.passwords import hash_password
|
||||
from app.recordings.recorder import verify_recording
|
||||
from app.ssh_proxy.proxy import HostKeyDiscoveryError, HostNotConfiguredError, discover_and_store_host_key
|
||||
from app.ssh_proxy.proxy import (
|
||||
HostKeyDiscoveryError,
|
||||
HostNotConfiguredError,
|
||||
PrivateKeyUnusableError,
|
||||
discover_and_store_host_key,
|
||||
import_private_key_material,
|
||||
)
|
||||
from app.tenancy import TenantScope, resolve_host_group_tenant, resolve_host_tenant, tenant_user_ids
|
||||
|
||||
logger = logging.getLogger("jumphost.admin")
|
||||
@ -725,15 +731,15 @@ async def create_host(
|
||||
INSERT INTO hosts (
|
||||
host_group_id, hostname, address, protocol, port, os_type,
|
||||
ssh_host_key_fingerprint, ssh_username, rdp_username, rdp_domain,
|
||||
rdp_require_nla, clipboard_enabled, file_transfer_enabled
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
rdp_require_nla, clipboard_enabled, file_transfer_enabled, rdp_ignore_cert
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
payload.host_group_id, payload.hostname, payload.address, payload.protocol,
|
||||
payload.port, payload.os_type, payload.ssh_host_key_fingerprint,
|
||||
payload.ssh_username, payload.rdp_username, payload.rdp_domain,
|
||||
int(payload.rdp_require_nla), int(payload.clipboard_enabled),
|
||||
int(payload.file_transfer_enabled),
|
||||
int(payload.file_transfer_enabled), int(payload.rdp_ignore_cert),
|
||||
),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
@ -797,7 +803,7 @@ async def get_host_detail(
|
||||
"SELECT h.id, h.hostname, h.address, h.protocol, h.port, h.os_type, h.host_group_id, "
|
||||
"h.ssh_host_key_fingerprint, h.ssh_username, h.rdp_username, h.rdp_domain, "
|
||||
"h.rdp_require_nla, h.clipboard_enabled, h.file_transfer_enabled, h.is_active, "
|
||||
"hg.tenant_id, t.name "
|
||||
"hg.tenant_id, t.name, h.rdp_ignore_cert "
|
||||
"FROM hosts h JOIN host_groups hg ON hg.id = h.host_group_id JOIN tenants t ON t.id = hg.tenant_id "
|
||||
"WHERE h.id = ?",
|
||||
(host_id,),
|
||||
@ -819,7 +825,8 @@ async def get_host_detail(
|
||||
"ssh_username": row[8], "rdp_username": row[9], "rdp_domain": row[10],
|
||||
"rdp_require_nla": bool(row[11]), "clipboard_enabled": bool(row[12]),
|
||||
"file_transfer_enabled": bool(row[13]), "is_active": bool(row[14]),
|
||||
"tenant_id": row[15], "tenant_name": row[16], "ssh_keys": ssh_keys,
|
||||
"tenant_id": row[15], "tenant_name": row[16], "rdp_ignore_cert": bool(row[17]),
|
||||
"ssh_keys": ssh_keys,
|
||||
"rdp_credentials_set": rdp_row is not None,
|
||||
"rdp_credentials_updated_at": rdp_row[0] if rdp_row else None,
|
||||
}
|
||||
@ -844,6 +851,7 @@ async def update_host(
|
||||
bool_field_map = {
|
||||
"rdp_require_nla": "rdp_require_nla", "clipboard_enabled": "clipboard_enabled",
|
||||
"file_transfer_enabled": "file_transfer_enabled", "is_active": "is_active",
|
||||
"rdp_ignore_cert": "rdp_ignore_cert",
|
||||
}
|
||||
fields, values = [], []
|
||||
payload_dict = payload.model_dump(exclude_unset=True)
|
||||
@ -1227,6 +1235,20 @@ async def list_role_names(admin: CurrentUser = Depends(require_admin_or_scope("r
|
||||
|
||||
# --- SSH-Keyverwaltung ---------------------------------------------------------
|
||||
|
||||
def _validate_private_key(pem: str, passphrase: str | None) -> None:
|
||||
"""Prueft, ob asyncssh das Material mit der angegebenen Passphrase laden
|
||||
kann, und uebersetzt einen Fehlschlag in HTTP 400 mit Klartextmeldung.
|
||||
|
||||
Der importierte Schluessel wird bewusst NICHT zurueckgegeben und nicht
|
||||
weiterverwendet -- er dient nur der Pruefung und wird sofort wieder
|
||||
verworfen (Konzept 6.4: Schluesselmaterial so kurz wie moeglich im
|
||||
Prozessspeicher)."""
|
||||
try:
|
||||
import_private_key_material(pem, passphrase)
|
||||
except PrivateKeyUnusableError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/ssh-keys", status_code=status.HTTP_201_CREATED)
|
||||
async def create_ssh_key(
|
||||
payload: SshKeyCreateRequest, request: Request,
|
||||
@ -1238,16 +1260,31 @@ async def create_ssh_key(
|
||||
conn = get_db()
|
||||
scope = _scope(admin)
|
||||
tenant_id = await _resolve_write_tenant(conn, scope, payload.tenant_id)
|
||||
# Schluessel SOFORT gegen asyncssh pruefen (inkl. Passphrase), statt einen
|
||||
# unbrauchbaren Schluessel entgegenzunehmen und den Fehler erst beim
|
||||
# ersten Verbindungsversuch eines Benutzers auftauchen zu lassen -- genau
|
||||
# so blieb ein passphrasegeschuetzter Schluessel bisher unbemerkt, bis
|
||||
# die Sitzung im Betrieb abbrach.
|
||||
_validate_private_key(payload.private_key_pem, payload.passphrase)
|
||||
encrypted = encrypt_secret(payload.private_key_pem.encode(), associated_data=b"ssh_private_key")
|
||||
passphrase_enc = (
|
||||
encrypt_secret(payload.passphrase.encode(), associated_data=b"ssh_key_passphrase")
|
||||
if payload.passphrase
|
||||
else None
|
||||
)
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type, tenant_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type, tenant_id),
|
||||
"INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type, "
|
||||
"tenant_id, passphrase_enc) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type,
|
||||
tenant_id, passphrase_enc),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
await write_audit_event(
|
||||
conn, event_type="ssh_key_created", user_id=admin.id, client_ip=_client_ip(request),
|
||||
details={"id": new_id, "label": payload.label, "key_type": payload.key_type},
|
||||
details={
|
||||
"id": new_id, "label": payload.label, "key_type": payload.key_type,
|
||||
"has_passphrase": bool(payload.passphrase),
|
||||
},
|
||||
)
|
||||
await conn.commit()
|
||||
return {"id": new_id, "label": payload.label}
|
||||
@ -1260,7 +1297,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.expires_at, sk.tenant_id, t.name, (sk.passphrase_enc IS NOT NULL) "
|
||||
"FROM ssh_keys sk JOIN tenants t ON t.id = sk.tenant_id "
|
||||
f"WHERE 1=1{tenant_filter} ORDER BY sk.id",
|
||||
params,
|
||||
@ -1271,6 +1308,9 @@ async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh
|
||||
"id": r[0], "label": r[1], "owner_user_id": r[2], "key_type": r[3],
|
||||
"created_at": r[4], "rotated_at": r[5], "expires_at": r[6],
|
||||
"tenant_id": r[7], "tenant_name": r[8],
|
||||
# Nur die Tatsache, NIE die Passphrase selbst -- kein Endpunkt
|
||||
# dieser Anwendung gibt jemals Klartext-Geheimnisse zurueck.
|
||||
"has_passphrase": bool(r[9]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@ -1298,6 +1338,34 @@ async def update_ssh_key(
|
||||
"Fuer eine Rotation muessen private_key_pem, public_key UND key_type gemeinsam angegeben werden",
|
||||
)
|
||||
|
||||
# "passphrase" wird nur angefasst, wenn das Feld im Request tatsaechlich
|
||||
# vorkommt -- ein Update von z.B. nur dem Label darf eine hinterlegte
|
||||
# Passphrase nicht stillschweigend loeschen. Explizit uebergebenes null
|
||||
# oder "" entfernt sie dagegen bewusst.
|
||||
passphrase_given = "passphrase" in payload.model_fields_set
|
||||
passphrase = payload.passphrase or None
|
||||
|
||||
if rotating:
|
||||
# Neues Material immer sofort pruefen (inkl. der -- ggf. neuen --
|
||||
# Passphrase), damit ein unbrauchbarer Schluessel gar nicht erst in
|
||||
# die Datenbank kommt.
|
||||
_validate_private_key(payload.private_key_pem, passphrase)
|
||||
elif passphrase_given:
|
||||
# Nur die Passphrase wird nachgetragen/geaendert: gegen das BEREITS
|
||||
# gespeicherte Schluesselmaterial pruefen. Das ist der Weg, einen
|
||||
# bereits hinterlegten, passphrasegeschuetzten Schluessel wieder
|
||||
# benutzbar zu machen, ohne ihn neu hochzuladen.
|
||||
row = await (
|
||||
await conn.execute("SELECT private_key_enc FROM ssh_keys WHERE id = ?", (key_id,))
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "SSH-Key nicht gefunden")
|
||||
stored_pem = decrypt_secret(row[0], associated_data=b"ssh_private_key")
|
||||
try:
|
||||
_validate_private_key(stored_pem.decode(), passphrase)
|
||||
finally:
|
||||
del stored_pem
|
||||
|
||||
fields, values = [], []
|
||||
if payload.label is not None:
|
||||
fields.append("label = ?"); values.append(payload.label)
|
||||
@ -1308,13 +1376,27 @@ async def update_ssh_key(
|
||||
fields += ["private_key_enc = ?", "public_key = ?", "key_type = ?",
|
||||
"rotated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"]
|
||||
values += [encrypted, payload.public_key, payload.key_type]
|
||||
if rotating or passphrase_given:
|
||||
# Bei einer Rotation wird die Passphrase in JEDEM Fall neu gesetzt --
|
||||
# eine zum alten Schluessel gehoerende Passphrase darf nicht am neuen
|
||||
# Schluessel haengenbleiben.
|
||||
fields.append("passphrase_enc = ?")
|
||||
values.append(
|
||||
encrypt_secret(passphrase.encode(), associated_data=b"ssh_key_passphrase")
|
||||
if passphrase
|
||||
else None
|
||||
)
|
||||
if not fields:
|
||||
return {"status": "ok", "changed": False}
|
||||
values.append(key_id)
|
||||
await conn.execute(f"UPDATE ssh_keys SET {', '.join(fields)} WHERE id = ?", values)
|
||||
await write_audit_event(
|
||||
conn, event_type="ssh_key_updated", user_id=admin.id, client_ip=_client_ip(request),
|
||||
details={"id": key_id, "rotated": rotating},
|
||||
details={
|
||||
"id": key_id, "rotated": rotating,
|
||||
"passphrase_changed": bool(rotating or passphrase_given),
|
||||
"has_passphrase": bool(passphrase),
|
||||
},
|
||||
)
|
||||
await conn.commit()
|
||||
return {"status": "ok", "changed": True}
|
||||
|
||||
Reference in New Issue
Block a user