feat(kiosk): Stufe 2 – Ed25519-Auth, CLI-Tool, neue KioskDevicesPage
2A – Backend Ed25519-Verifizierung: - app/core/kiosk_security.py (NEU): verify_kiosk_request() Dependency - Timestamp-Check (30s Drift), Nonce-Cache (Redis/In-Memory), IP-Whitelist - Ed25519-Signatur über METHOD+PATH+TIMESTAMP+NONCE+sha256(BODY) - PEM + OpenSSH Key-Format unterstützt - app/routers/kiosk.py: approve/revoke Endpunkte, POST /heartbeat (Ed25519-signiert) - app/services/kiosk_service.py: token-basierte Methoden entfernt, approve/revoke/heartbeat - app/schemas/kiosk.py: KioskDeviceOut mit heartbeat_status, HeartbeatRequest/Response 2B – CLI-Tool: - cli.py (NEU, 529 Zeilen): Typer-CLI mit kiosk add/list/approve/revoke/info - Public-Key-Fingerprint (SHA256), Rich-Tabellen, CIDR-Validierung - Direkter DB-Zugriff mit RLS-Bypass 2C – Frontend: - KioskDevicesPage.tsx: Zwei-Tab-Layout (Wartet/Aktiv), Status-Ampel, Auto-Refresh 30s, Ed25519-Workflow (kein Token mehr) - Layout.tsx: KioskHealthBadge (online/total, 30s Refresh, nur COMPANY_ADMIN) requirements.txt: typer>=0.12.0, rich>=13.7.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user