62c4e742ab
CRITICAL: - C-1: LDAP tls_verify Default False → True (MITM-Schutz) - C-2: TOTP-Secret Fernet-verschlüsselt in DB (statt Plaintext) - core/crypto.py: encrypt_value() / decrypt_value() helper - Migration 0026: totp_secret VARCHAR(64→500), ldap tls_verify default=true - _totp_plain() helper mit Legacy-Fallback für bestehende Werte HIGH: - H-1: Kiosk Nonce-Cache asyncio.Lock (Race Condition behoben) - H-2: File-Upload-Limit 10 MB (import_kimai.py + users.py CSV-Import) - H-3: CORS allow_methods/allow_headers explizit eingeschränkt (war *) - H-4: TrustedHostMiddleware aktiviert wenn ALLOWED_HOSTS gesetzt MEDIUM: - M-1: IP-Logging nutzt X-Forwarded-For hinter nginx-Proxy - M-4: Audit-Log für password_changed, totp_enabled, totp_disabled - M-5: CalDAV verify_ssl in Production erzwungen (_effective_verify_ssl) 152/152 Tests grün Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
251 lines
9.3 KiB
Python
251 lines
9.3 KiB
Python
"""
|
|
Ed25519-basierte Request-Verifizierung für Kiosk-Geräte.
|
|
|
|
Jeder Kiosk-Request muss folgende HTTP-Header mitschicken:
|
|
X-Kiosk-Key-Id : UUID des Geräts (entspricht KioskDevice.id)
|
|
X-Kiosk-Timestamp : Unix-Zeit in Sekunden (max 30s Drift zum Server)
|
|
X-Kiosk-Nonce : Einmalige UUID (Replay-Schutz, 60s-Fenster via Redis)
|
|
X-Kiosk-Signature : Base64(Ed25519-Sig über "METHOD PATH TIMESTAMP NONCE sha256(BODY)")
|
|
|
|
Bei Fehler: 401 Unauthorized (oder 403 für IP-Whitelist-Verletzungen).
|
|
"""
|
|
|
|
import asyncio
|
|
import base64
|
|
import hashlib
|
|
import ipaddress
|
|
import logging
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from cryptography.exceptions import InvalidSignature
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
from cryptography.hazmat.primitives.serialization import load_pem_public_key, load_ssh_public_key
|
|
from fastapi import Depends, Header, HTTPException, Request
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.config import settings
|
|
from app.core.database import get_db
|
|
from app.models.kiosk_device import KioskDevice, KioskDeviceStatus
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ── Nonce-Cache (Redis primär, In-Memory-Fallback mit asyncio.Lock) ──────────
|
|
|
|
_nonce_cache: dict[str, float] = {} # nonce → expires_at (epoch)
|
|
_nonce_lock: asyncio.Lock | None = None # lazy init (Loop-abhängig)
|
|
_NONCE_TTL = 60 # Sekunden
|
|
|
|
|
|
def _get_nonce_lock() -> asyncio.Lock:
|
|
"""Lazy-initialized asyncio.Lock (thread-safe, event-loop-abhängig)."""
|
|
global _nonce_lock
|
|
if _nonce_lock is None:
|
|
_nonce_lock = asyncio.Lock()
|
|
return _nonce_lock
|
|
|
|
|
|
def _cleanup_nonce_cache() -> None:
|
|
"""Abgelaufene Einträge aus dem In-Memory-Cache entfernen (muss unter Lock aufgerufen werden)."""
|
|
now = time.time()
|
|
expired = [k for k, v in _nonce_cache.items() if v <= now]
|
|
for k in expired:
|
|
del _nonce_cache[k]
|
|
|
|
|
|
async def _check_and_set_nonce(nonce: str) -> bool:
|
|
"""
|
|
Prüft ob die Nonce schon gesehen wurde und speichert sie.
|
|
Gibt True zurück wenn die Nonce NEU ist (Request erlaubt).
|
|
Gibt False zurück wenn die Nonce bereits bekannt ist (Replay!).
|
|
|
|
Verwendet Redis als primären Nonce-Store. Falls Redis nicht erreichbar,
|
|
wird ein asyncio.Lock-geschützter In-Memory-Fallback verwendet.
|
|
Kritisch: Redis-Ausfall bei laufenden Kiosk-Requests ermöglicht theoretisch
|
|
Replay im Fallback-Fenster → Redis sollte in Production HA sein.
|
|
"""
|
|
try:
|
|
import redis.asyncio as aioredis
|
|
r: Any = aioredis.from_url(settings.redis_url, decode_responses=True)
|
|
key = f"kiosk:nonce:{nonce}"
|
|
# SETNX: setzt nur wenn nicht vorhanden, gibt 1 zurück wenn gesetzt
|
|
result = await r.set(key, "1", ex=_NONCE_TTL, nx=True)
|
|
await r.aclose()
|
|
return result is not None # None = bereits vorhanden
|
|
except Exception as e:
|
|
logger.warning("Redis nicht erreichbar, nutze In-Memory-Nonce-Cache (Lock-geschützt): %s", e)
|
|
# Fallback: In-Memory mit asyncio.Lock gegen Race Conditions
|
|
lock = _get_nonce_lock()
|
|
async with lock:
|
|
_cleanup_nonce_cache()
|
|
now = time.time()
|
|
if nonce in _nonce_cache:
|
|
return False
|
|
_nonce_cache[nonce] = now + _NONCE_TTL
|
|
return True
|
|
|
|
|
|
# ── Öffentlichen Schlüssel laden ─────────────────────────────────────────────
|
|
|
|
def _load_ed25519_public_key(public_key_str: str) -> Ed25519PublicKey:
|
|
"""
|
|
Lädt einen Ed25519-Public-Key aus PEM- oder OpenSSH-Format.
|
|
Wirft ValueError bei ungültigem Format.
|
|
"""
|
|
key_bytes = public_key_str.strip().encode()
|
|
|
|
# OpenSSH-Format (beginnt mit "ssh-ed25519 ...")
|
|
if key_bytes.startswith(b"ssh-ed25519"):
|
|
key = load_ssh_public_key(key_bytes)
|
|
if not isinstance(key, Ed25519PublicKey):
|
|
raise ValueError("Schlüssel ist kein Ed25519-Schlüssel.")
|
|
return key
|
|
|
|
# PEM-Format (beginnt mit "-----BEGIN ...")
|
|
if key_bytes.startswith(b"-----"):
|
|
key = load_pem_public_key(key_bytes)
|
|
if not isinstance(key, Ed25519PublicKey):
|
|
raise ValueError("Schlüssel ist kein Ed25519-Schlüssel.")
|
|
return key
|
|
|
|
raise ValueError("Unbekanntes Schlüsselformat. PEM oder OpenSSH erwartet.")
|
|
|
|
|
|
# ── IP-Whitelist prüfen ───────────────────────────────────────────────────────
|
|
|
|
def _check_ip_whitelist(client_ip: str, ip_whitelist: str) -> bool:
|
|
"""
|
|
Prüft ob client_ip in einer kommaseparierten CIDR-Liste enthalten ist.
|
|
Gibt True zurück wenn erlaubt, False wenn nicht.
|
|
"""
|
|
try:
|
|
client_addr = ipaddress.ip_address(client_ip)
|
|
except ValueError:
|
|
logger.warning("Ungültige Client-IP: %s", client_ip)
|
|
return False
|
|
|
|
for cidr in ip_whitelist.split(","):
|
|
cidr = cidr.strip()
|
|
if not cidr:
|
|
continue
|
|
try:
|
|
network = ipaddress.ip_network(cidr, strict=False)
|
|
if client_addr in network:
|
|
return True
|
|
except ValueError:
|
|
logger.warning("Ungültiger CIDR-Eintrag in ip_whitelist: %s", cidr)
|
|
continue
|
|
|
|
return False
|
|
|
|
|
|
# ── FastAPI Dependency ────────────────────────────────────────────────────────
|
|
|
|
async def verify_kiosk_request(
|
|
request: Request,
|
|
x_kiosk_key_id: str = Header(..., alias="X-Kiosk-Key-Id"),
|
|
x_kiosk_timestamp: str = Header(..., alias="X-Kiosk-Timestamp"),
|
|
x_kiosk_nonce: str = Header(..., alias="X-Kiosk-Nonce"),
|
|
x_kiosk_signature: str = Header(..., alias="X-Kiosk-Signature"),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> KioskDevice:
|
|
"""
|
|
FastAPI-Dependency: Verifiziert einen signierten Kiosk-Request.
|
|
|
|
Schritte:
|
|
1. Timestamp-Check (max 30s Drift)
|
|
2. Nonce-Check (Replay-Schutz)
|
|
3. Gerät laden + Status prüfen
|
|
4. IP-Whitelist (falls konfiguriert)
|
|
5. Ed25519-Signatur verifizieren
|
|
6. last_heartbeat_at aktualisieren
|
|
|
|
Gibt das verifizierte KioskDevice zurück.
|
|
"""
|
|
# 1. Timestamp-Check
|
|
try:
|
|
request_time = float(x_kiosk_timestamp)
|
|
except ValueError:
|
|
raise HTTPException(status_code=401, detail="Ungültiger Timestamp.")
|
|
|
|
drift = abs(time.time() - request_time)
|
|
if drift > 30:
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail=f"Timestamp liegt zu weit in der Vergangenheit oder Zukunft ({drift:.1f}s Abweichung, max 30s).",
|
|
)
|
|
|
|
# 2. Nonce-Check
|
|
# UUID-Format validieren (grobe Prüfung)
|
|
try:
|
|
UUID(x_kiosk_nonce)
|
|
except ValueError:
|
|
raise HTTPException(status_code=401, detail="Nonce muss eine gültige UUID sein.")
|
|
|
|
nonce_ok = await _check_and_set_nonce(x_kiosk_nonce)
|
|
if not nonce_ok:
|
|
raise HTTPException(status_code=401, detail="Replay-Angriff erkannt: Nonce bereits verwendet.")
|
|
|
|
# 3. Gerät laden
|
|
try:
|
|
device_uuid = UUID(x_kiosk_key_id)
|
|
except ValueError:
|
|
raise HTTPException(status_code=401, detail="X-Kiosk-Key-Id muss eine gültige UUID sein.")
|
|
|
|
device = await db.scalar(
|
|
select(KioskDevice).where(KioskDevice.id == device_uuid)
|
|
)
|
|
|
|
if device is None:
|
|
raise HTTPException(status_code=401, detail="Kiosk-Gerät nicht gefunden.")
|
|
|
|
if device.status != KioskDeviceStatus.APPROVED:
|
|
raise HTTPException(
|
|
status_code=401,
|
|
detail=f"Kiosk-Gerät ist nicht freigegeben (Status: {device.status.value}).",
|
|
)
|
|
|
|
if not device.public_key:
|
|
raise HTTPException(status_code=401, detail="Kein Public Key für dieses Gerät registriert.")
|
|
|
|
# 4. IP-Whitelist prüfen (optional)
|
|
if device.ip_whitelist:
|
|
client_ip = request.client.host if request.client else ""
|
|
if not client_ip:
|
|
raise HTTPException(status_code=403, detail="Client-IP nicht ermittelbar, IP-Whitelist aktiv.")
|
|
if not _check_ip_whitelist(client_ip, device.ip_whitelist):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"Client-IP {client_ip} ist nicht in der erlaubten IP-Whitelist.",
|
|
)
|
|
|
|
# 5. Signatur verifizieren
|
|
body = await request.body()
|
|
body_hash = hashlib.sha256(body).hexdigest()
|
|
message = f"{request.method} {request.url.path} {x_kiosk_timestamp} {x_kiosk_nonce} {body_hash}"
|
|
|
|
try:
|
|
signature_bytes = base64.b64decode(x_kiosk_signature)
|
|
except Exception:
|
|
raise HTTPException(status_code=401, detail="Signatur ist kein gültiges Base64.")
|
|
|
|
try:
|
|
pub_key = _load_ed25519_public_key(device.public_key)
|
|
except ValueError as e:
|
|
logger.error("Fehler beim Laden des Public Keys für Gerät %s: %s", device.id, e)
|
|
raise HTTPException(status_code=401, detail="Public Key des Geräts ist ungültig.")
|
|
|
|
try:
|
|
pub_key.verify(signature_bytes, message.encode())
|
|
except InvalidSignature:
|
|
raise HTTPException(status_code=401, detail="Ungültige Signatur.")
|
|
|
|
# 6. Heartbeat-Zeitstempel aktualisieren
|
|
device.last_heartbeat_at = datetime.now(timezone.utc)
|
|
await db.flush()
|
|
|
|
return device
|