second commit

This commit is contained in:
2026-08-19 22:33:19 +02:00
parent 411812e954
commit 199f306993
107 changed files with 5984 additions and 0 deletions

0
app/security/__init__.py Normal file
View File

70
app/security/audit.py Normal file
View File

@ -0,0 +1,70 @@
"""
Manipulationssicheres, hash-verkettetes Audit-Log (siehe Konzept 4.7 / 6.1).
Jeder Eintrag verkettet sich kryptographisch mit seinem Vorgaenger:
entry_hash = sha256(prev_hash || ts || event_type || details_json)
Nachtraegliches Aendern oder Herausloeschen eines Eintrags bricht die Kette
ab dieser Stelle - erkennbar durch verify_chain(). Zusaetzlich verhindern
DB-Trigger (0001_initial.sql) UPDATE/DELETE auf Anwendungsebene.
"""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from typing import Any
import aiosqlite
GENESIS_HASH = "0" * 64
def _entry_hash(prev_hash: str, ts: str, event_type: str, details_json: str) -> str:
payload = f"{prev_hash}|{ts}|{event_type}|{details_json}".encode()
return hashlib.sha256(payload).hexdigest()
async def write_audit_event(
conn: aiosqlite.Connection,
*,
event_type: str,
user_id: int | None,
client_ip: str | None,
details: dict[str, Any],
) -> None:
"""Schreibt einen Audit-Eintrag; haengt ihn an die bestehende Hash-Chain an.
Muss innerhalb derselben Transaktion wie die fachliche Aktion laufen (oder
zumindest unmittelbar danach), damit kein Ereignis unauditiert bleibt.
"""
cursor = await conn.execute("SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1")
row = await cursor.fetchone()
prev_hash = row[0] if row else GENESIS_HASH
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
details_json = json.dumps(details, sort_keys=True, ensure_ascii=False)
entry_hash = _entry_hash(prev_hash, ts, event_type, details_json)
await conn.execute(
"INSERT INTO audit_log (ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash),
)
async def verify_chain(conn: aiosqlite.Connection) -> tuple[bool, int | None]:
"""Prueft die gesamte Audit-Log-Kette. Rueckgabe: (intakt?, erste kaputte id)."""
prev_hash = GENESIS_HASH
cursor = await conn.execute(
"SELECT id, ts, event_type, details_json, prev_hash, entry_hash FROM audit_log ORDER BY id ASC"
)
async for row in cursor:
entry_id, ts, event_type, details_json, stored_prev, stored_entry = row
if stored_prev != prev_hash:
return False, entry_id
expected = _entry_hash(prev_hash, ts, event_type, details_json)
if expected != stored_entry:
return False, entry_id
prev_hash = stored_entry
return True, None

38
app/security/av_scan.py Normal file
View File

@ -0,0 +1,38 @@
"""
AV-Scan-Hook fuer Datei-Uploads (Konzept 4.3/6.6).
Bewusst als duenner Wrapper um ein optionales ClamAV (clamd) gehalten: ist
kein Scanner konfiguriert/erreichbar, wird das Ergebnis "skipped" vermerkt
statt die Datei stillschweigend als "sauber" zu markieren -- Admins sehen im
Audit-/Filetransfer-Log damit ehrlich, ob wirklich gescannt wurde.
"""
from __future__ import annotations
import shutil
import subprocess # nosec B404 -- benoetigt fuer den optionalen ClamAV-Aufruf, siehe unten
def scan_bytes(data: bytes) -> str:
"""Rueckgabe: 'clean', 'infected:<signature>' oder 'skipped:<grund>'."""
clamdscan = shutil.which("clamdscan")
if not clamdscan:
return "skipped:clamdscan_not_installed"
try:
# Argumentliste ist vollstaendig fest (kein shell=True, keine
# Nutzereingabe im Kommando selbst); die hochgeladenen Datei-Bytes
# werden ausschliesslich ueber stdin (input=data) uebergeben, nie als
# Kommandozeilen-/Pfadargument -- Command-Injection ueber Dateinamen
# o.ae. ist damit ausgeschlossen.
proc = subprocess.run( # nosec B603
[clamdscan, "--stdout", "--no-summary", "-"],
input=data, capture_output=True, timeout=30,
)
except (subprocess.TimeoutExpired, OSError):
return "skipped:scan_error"
output = proc.stdout.decode(errors="replace")
if proc.returncode == 0:
return "clean"
if proc.returncode == 1:
return f"infected:{output.strip()}"
return "skipped:scan_error"

32
app/security/crypto.py Normal file
View File

@ -0,0 +1,32 @@
"""
AES-256-GCM Verschluesselung fuer Secrets at rest (SSH-Private-Keys, TOTP-Secrets).
Prinzip (siehe Konzept 6.4): Der Key-Encryption-Key (KEK) liegt NICHT in der
Datenbank, sondern kommt aus app.config.settings (systemd-creds/Env). Jeder
verschluesselte Datensatz erhaelt einen frischen, zufaelligen Nonce; Nonce +
Ciphertext + Auth-Tag werden gemeinsam gespeichert.
"""
from __future__ import annotations
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from app.config import settings
NONCE_LEN = 12 # 96 Bit, empfohlene GCM-Noncelaenge
def encrypt_secret(plaintext: bytes, *, associated_data: bytes = b"") -> bytes:
"""Verschluesselt plaintext mit dem globalen KEK. Rueckgabe: nonce || ciphertext."""
aesgcm = AESGCM(settings.kek)
nonce = os.urandom(NONCE_LEN)
ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data or None)
return nonce + ciphertext
def decrypt_secret(blob: bytes, *, associated_data: bytes = b"") -> bytes:
"""Entschluesselt einen mit encrypt_secret() erzeugten Blob."""
aesgcm = AESGCM(settings.kek)
nonce, ciphertext = blob[:NONCE_LEN], blob[NONCE_LEN:]
return aesgcm.decrypt(nonce, ciphertext, associated_data or None)

28
app/security/passwords.py Normal file
View File

@ -0,0 +1,28 @@
"""Argon2id-Passwort-Hashing (siehe Konzept 6.2)."""
from __future__ import annotations
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHash
# Parameter angelehnt an aktuelle OWASP-Empfehlung; in Produktion je nach
# Server-Hardware kalibrieren (siehe Konzept 6.2).
_hasher = PasswordHasher(time_cost=2, memory_cost=19 * 1024, parallelism=1)
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password_hash: str, password: str) -> bool:
try:
_hasher.verify(password_hash, password)
except (VerifyMismatchError, InvalidHash):
return False
return True
def needs_rehash(password_hash: str) -> bool:
try:
return _hasher.check_needs_rehash(password_hash)
except InvalidHash:
return True

View File

@ -0,0 +1,29 @@
"""Kurzlebige, signierte Tokens fuer den Zwischenschritt Passwort -> TOTP.
Es wird bewusst KEIN Session-Cookie ausgestellt, solange der zweite Faktor
nicht bestaetigt ist (siehe Konzept 6.2: "kein Login ohne TOTP moeglich").
"""
from __future__ import annotations
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from app.config import settings
_PENDING_MAX_AGE_S = 300 # 5 Minuten Zeitfenster fuer den TOTP-Schritt
_serializer = URLSafeTimedSerializer(settings.session_secret.hex(), salt="jumphost-pending-totp")
def create_pending_token(user_id: int) -> str:
return _serializer.dumps({"uid": user_id})
def decode_pending_token(token: str) -> int | None:
try:
data = _serializer.loads(token, max_age=_PENDING_MAX_AGE_S)
except (BadSignature, SignatureExpired):
return None
try:
return int(data["uid"])
except (KeyError, TypeError, ValueError):
return None

View File

@ -0,0 +1,34 @@
"""
Einfacher In-Memory Rate-Limiter fuer den Login-Endpunkt (pro Quell-IP).
Fuer einen Single-Process-ASGI-Deployment (siehe Konzept: kleine/mittlere
Umgebung) ausreichend. Bei horizontaler Skalierung auf mehrere Prozesse/Hosts
muss dies durch einen geteilten Store (z.B. Redis) ersetzt werden -- als
Erweiterungspunkt bewusst hinter einer kleinen Klasse gekapselt.
"""
from __future__ import annotations
import time
from collections import defaultdict, deque
class SlidingWindowRateLimiter:
def __init__(self, max_events: int, window_seconds: int) -> None:
self.max_events = max_events
self.window_seconds = window_seconds
self._events: dict[str, deque[float]] = defaultdict(deque)
def allow(self, key: str) -> bool:
now = time.time()
window = self._events[key]
while window and now - window[0] > self.window_seconds:
window.popleft()
if len(window) >= self.max_events:
return False
window.append(now)
return True
# Max. 10 Login-Versuche pro Minute und Quell-IP; ergaenzt den
# Account-basierten Lockout in app/auth/routes.py.
login_rate_limiter = SlidingWindowRateLimiter(max_events=10, window_seconds=60)

70
app/security/sessions.py Normal file
View File

@ -0,0 +1,70 @@
"""
Signierte, serverseitig invalidierbare Session-Cookies.
Kein separates Session-Store noetig: Das Cookie traegt user_id,
session_version (fuer harte Invalidierung, z.B. bei Passwortwechsel) und
zwei Zeitstempel (Login-Zeit fuer den absoluten Timeout, Last-Seen fuer den
gleitenden Idle-Timeout). Signatur ueber einen vom KEK getrennten Secret
(Schluesseltrennung, Konzept 6.2).
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from itsdangerous import BadSignature, URLSafeSerializer
from app.config import settings
_serializer = URLSafeSerializer(settings.session_secret.hex(), salt="jumphost-session")
SESSION_COOKIE_NAME = "jh_session"
@dataclass
class SessionPayload:
user_id: int
session_version: int
login_ts: float
last_seen_ts: float
def create_session_token(user_id: int, session_version: int) -> str:
now = time.time()
payload = {"uid": user_id, "sv": session_version, "iat": now, "seen": now}
return _serializer.dumps(payload)
def refresh_session_token(payload: SessionPayload) -> str:
data = {
"uid": payload.user_id,
"sv": payload.session_version,
"iat": payload.login_ts,
"seen": time.time(),
}
return _serializer.dumps(data)
def decode_session_token(token: str) -> SessionPayload | None:
try:
data = _serializer.loads(token)
except BadSignature:
return None
try:
return SessionPayload(
user_id=int(data["uid"]),
session_version=int(data["sv"]),
login_ts=float(data["iat"]),
last_seen_ts=float(data["seen"]),
)
except (KeyError, TypeError, ValueError):
return None
def is_expired(payload: SessionPayload) -> bool:
now = time.time()
if now - payload.last_seen_ts > settings.session_idle_timeout_s:
return True
if now - payload.login_ts > settings.session_absolute_timeout_s:
return True
return False

52
app/security/totp.py Normal file
View File

@ -0,0 +1,52 @@
"""
TOTP-Enrollment und -Verifikation (RFC 6238) inkl. Recovery-Codes.
Pflicht-2FA: siehe Konzept 4.5 / 6.2. Das TOTP-Secret wird mit einem eigenen
AAD-Kontext ("totp") verschluesselt gespeichert -- Schluesseltrennung vom
SSH-Key-Material ist ueber den associated_data-Parameter realisiert (beide
nutzen zwar denselben KEK, sind aber durch AAD kontextgebunden und nicht
gegeneinander austauschbar).
"""
from __future__ import annotations
import hashlib
import secrets
import pyotp
from app.security.crypto import decrypt_secret, encrypt_secret
_TOTP_AAD = b"totp_secret"
def generate_totp_secret() -> str:
return pyotp.random_base32()
def encrypt_totp_secret(secret: str) -> bytes:
return encrypt_secret(secret.encode(), associated_data=_TOTP_AAD)
def decrypt_totp_secret(blob: bytes) -> str:
return decrypt_secret(blob, associated_data=_TOTP_AAD).decode()
def provisioning_uri(secret: str, username: str, issuer: str = "Jumphost") -> str:
return pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=issuer)
def verify_totp_code(secret: str, code: str) -> bool:
"""Verifiziert mit +-1 Zeitfenster Toleranz gegen Clock-Drift."""
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1)
def generate_recovery_codes(count: int = 10) -> list[str]:
"""Erzeugt Einmal-Recovery-Codes im Klartext (nur zur einmaligen Anzeige)."""
return [secrets.token_hex(5) for _ in range(count)]
def hash_recovery_code(code: str) -> str:
# Recovery-Codes sind hochentropisch (40 Bit hex) -- ein schneller,
# gesalzener Hash reicht hier aus; dennoch SHA-256 mit Pfeffer aus KEK-Kontext.
return hashlib.sha256(code.encode() + b"recovery_code_pepper").hexdigest()