second commit
This commit is contained in:
0
app/security/__init__.py
Normal file
0
app/security/__init__.py
Normal file
70
app/security/audit.py
Normal file
70
app/security/audit.py
Normal 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
38
app/security/av_scan.py
Normal 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
32
app/security/crypto.py
Normal 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
28
app/security/passwords.py
Normal 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
|
||||
29
app/security/pending_totp.py
Normal file
29
app/security/pending_totp.py
Normal 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
|
||||
34
app/security/rate_limit.py
Normal file
34
app/security/rate_limit.py
Normal 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
70
app/security/sessions.py
Normal 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
52
app/security/totp.py
Normal 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()
|
||||
Reference in New Issue
Block a user