4dc69137dd
H-1: company.settings als typisiertes Sub-Schema - schemas/company.py: CompanySettingsUpdate mit extra=forbid - Nur bekannte Keys (carryover_expires_month/day) erlaubt - Unbekannte Keys → HTTP 422 H-5: SQL-Injection defensiv absichern - dependencies.py: UUID-Round-Trip str(_uuid.UUID(...)) + Sicherheitskommentar H-6: CalDAV DNS-Rebinding-Schutz - caldav_service.py: PinnedIPTransport — IP einmal auflösen, beim Request fixieren - _validate_caldav_url gibt aufgelöste IP zurück - Alle HTTP-Methoden nutzen PinnedIPTransport H-7: Heartbeat-Timestamp nach Route-Logik - kiosk_security.py: last_heartbeat_at-Update aus Dependency entfernt - kiosk_service.py: Update erst in process_heartbeat() nach erfolgreicher Auth Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
270 lines
10 KiB
Python
270 lines
10 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.")
|
||
|
||
|
||
# ── Client-IP ermitteln (nginx-Proxy-sicher) ─────────────────────────────────
|
||
|
||
def _get_client_ip(request: Request) -> str:
|
||
"""Liest die echte Client-IP auch hinter nginx-Proxy.
|
||
|
||
nginx setzt X-Real-IP auf die echte Client-IP.
|
||
Ohne diesen Header würde request.client.host hinter nginx immer
|
||
127.0.0.1 zurückgeben, womit IP-Whitelisting wirkungslos wäre.
|
||
"""
|
||
real_ip = request.headers.get("X-Real-IP")
|
||
if real_ip:
|
||
return real_ip.strip()
|
||
# Fallback: direkte Verbindungs-IP (lokal/dev)
|
||
return request.client.host if request.client else "unknown"
|
||
|
||
|
||
# ── 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
|
||
|
||
Gibt das verifizierte KioskDevice zurück.
|
||
Hinweis: last_heartbeat_at wird NICHT hier gesetzt (H-7) – nur der Heartbeat-
|
||
Endpoint setzt es, nach erfolgreicher Route-Logik.
|
||
"""
|
||
# 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 = _get_client_ip(request)
|
||
if not client_ip or client_ip == "unknown":
|
||
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.")
|
||
|
||
# Hinweis: last_heartbeat_at wird NICHT hier gesetzt, sondern erst im
|
||
# Heartbeat-Route-Handler (process_heartbeat). So wird der Timestamp nur
|
||
# committed wenn die gesamte Route-Logik erfolgreich war, nicht bereits
|
||
# bei jedem signierten Request. Andere Endpunkte (auth/pin etc.) aktualisieren
|
||
# last_heartbeat_at bewusst nicht – nur echter Heartbeat zählt als Liveness-Signal.
|
||
|
||
return device
|