""" 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 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 wenn verfügbar, sonst In-Memory-Fallback) ───────────── _nonce_cache: dict[str, float] = {} # nonce → expires_at (epoch) _NONCE_TTL = 60 # Sekunden def _cleanup_nonce_cache() -> None: """Abgelaufene Einträge aus dem In-Memory-Cache entfernen.""" 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!). """ 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: %s", e) # Fallback: In-Memory _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