From 35fcea90f4ec746d5897384b30430dfb59a43e15 Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 24 May 2026 12:23:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(kiosk):=20Stufe=203=20=E2=80=93=20ServiceW?= =?UTF-8?q?orker,=20WebCrypto=20Setup-Flow,=20Kiosk-UI,=2015=20Security-Te?= =?UTF-8?q?sts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3A – Frontend Kiosk-Modus: - public/kiosk-sw.js (NEU, 187 Zeilen): ServiceWorker signiert alle /api/v1/kiosk/ Requests automatisch mit Ed25519. Keypair-Generierung intern (non-extractable), Speicherung in IndexedDB. BroadcastChannel-Leader-Election für Heartbeat. - KioskSetupPage.tsx (NEU, 307 Zeilen): Enrollment-Flow unter /kiosk/setup. Keypair-Generierung via WebCrypto im ServiceWorker, Public Key als PEM anzeigen. Browser-Kompatibilitäts-Check (Ed25519 ab Chrome 113+). - KioskStampPage.tsx (NEU, 348 Zeilen): Kiosk-UI unter /kiosk. Live-Uhr mit Server-Zeit-Offset, Heartbeat-Loop 30s, Online/Offline-Indikator. - App.tsx: /kiosk und /kiosk/setup Routen außerhalb ProtectedRoute 3B – Tests: - tests/test_kiosk_security.py (NEU, 387 Zeilen): 15/15 Tests grün Abgedeckt: gültige Signatur, falscher Key, Replay-Schutz, Timestamp-Drift, Future-Timestamp, pending/revoked Device, unbekanntes Gerät, fehlende Header, Lifecycle-Tests, heartbeat_status nach Heartbeat Co-Authored-By: Claude Sonnet 4.6 --- DEVLOG.md | 20 ++ backend/tests/test_kiosk_security.py | 387 ++++++++++++++++++++++++++ frontend/DEVLOG.md | 20 ++ frontend/public/kiosk-sw.js | 187 +++++++++++++ frontend/src/App.tsx | 4 + frontend/src/pages/KioskSetupPage.tsx | 307 ++++++++++++++++++++ frontend/src/pages/KioskStampPage.tsx | 348 +++++++++++++++++++++++ 7 files changed, 1273 insertions(+) create mode 100644 backend/tests/test_kiosk_security.py create mode 100644 frontend/public/kiosk-sw.js create mode 100644 frontend/src/pages/KioskSetupPage.tsx create mode 100644 frontend/src/pages/KioskStampPage.tsx diff --git a/DEVLOG.md b/DEVLOG.md index 1098f1b..5eeb872 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -904,3 +904,23 @@ Keine Commits in dieser Session. - .../migrations/versions/0022_sick_note_config.py | 2 +- --- +## 2026-05-24 12:17 – 12:19 (2m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 58 ++++ +- backend/app/core/kiosk_security.py | 233 ++++++++++++++ +- backend/app/routers/kiosk.py | 119 +++++-- +- backend/app/schemas/kiosk.py | 70 ++++- +- backend/app/services/kiosk_service.py | 138 ++++++--- +- backend/cli.py | 529 ++++++++++++++++++++++++++++++++ +- backend/requirements.txt | 2 + +- frontend/DEVLOG.md | 16 + +- frontend/src/components/Layout.tsx | 71 ++++- +- frontend/src/pages/KioskDevicesPage.tsx | 412 +++++++++++++++++-------- + +--- diff --git a/backend/tests/test_kiosk_security.py b/backend/tests/test_kiosk_security.py new file mode 100644 index 0000000..68e4135 --- /dev/null +++ b/backend/tests/test_kiosk_security.py @@ -0,0 +1,387 @@ +""" +Ed25519-Kiosk-Sicherheitstests für TimeMaster. + +Testet: +- Gültiger signierter Heartbeat +- Ungültige Signatur → 401 +- Replay-Angriff (gleiche Nonce) → 401 +- Timestamp-Drift > 30s → 401 +- Pending-Gerät → 401 +- Gerät-Lifecycle (anlegen, genehmigen, sperren) +- Fehlende Kiosk-Header → 422 +- Geräteliste für Admin +""" +import base64 +import hashlib +import time +import uuid + +import pytest +import pytest_asyncio +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from httpx import AsyncClient + +pytestmark = pytest.mark.asyncio + +DEVICES_URL = "/api/v1/kiosk/devices" +HEARTBEAT_URL = "/api/v1/kiosk/heartbeat" + + +# ── Hilffunktion ────────────────────────────────────────────────────────────── + +def make_kiosk_headers( + device_id: str, + private_key: Ed25519PrivateKey, + method: str, + path: str, + body: bytes = b"", +) -> dict: + """Erzeugt gültige Kiosk-Signatur-Header für einen Request.""" + timestamp = str(int(time.time())) + nonce = str(uuid.uuid4()) + body_hash = hashlib.sha256(body).hexdigest() + message = f"{method} {path} {timestamp} {nonce} {body_hash}".encode() + signature = private_key.sign(message) + sig_b64 = base64.b64encode(signature).decode() + return { + "X-Kiosk-Key-Id": device_id, + "X-Kiosk-Timestamp": timestamp, + "X-Kiosk-Nonce": nonce, + "X-Kiosk-Signature": sig_b64, + } + + +# ── Fixtures ────────────────────────────────────────────────────────────────── + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def kiosk_keypair(): + """Ed25519-Keypair für Tests.""" + private_key = Ed25519PrivateKey.generate() + public_key_pem = ( + private_key.public_key() + .public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo) + .decode() + ) + return private_key, public_key_pem + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def kiosk_admin_headers(registered_user): + """Admin-Authorization-Header aus registered_user.""" + return {"Authorization": f"Bearer {registered_user['tokens']['access_token']}"} + + +@pytest_asyncio.fixture(scope="session", loop_scope="session") +async def approved_kiosk_device(client: AsyncClient, kiosk_admin_headers, kiosk_keypair): + """Registriert ein Kiosk-Gerät und genehmigt es direkt.""" + _, public_key_pem = kiosk_keypair + + resp = await client.post( + DEVICES_URL, + json={ + "name": "Test-Kiosk", + "location": "Testlabor", + "public_key": public_key_pem, + }, + headers=kiosk_admin_headers, + ) + assert resp.status_code == 201, resp.text + device = resp.json() + + approve_resp = await client.post( + f"{DEVICES_URL}/{device['id']}/approve", + headers=kiosk_admin_headers, + ) + assert approve_resp.status_code == 200, approve_resp.text + + return device + + +# ── Tests ───────────────────────────────────────────────────────────────────── + +async def test_heartbeat_valid(client: AsyncClient, approved_kiosk_device, kiosk_keypair): + """Gültiger signierter Heartbeat → 200 mit server_time, heartbeat_interval_sec, device_status.""" + private_key, _ = kiosk_keypair + device_id = approved_kiosk_device["id"] + body = b'{"queued_offline_entries": 0}' + headers = make_kiosk_headers(device_id, private_key, "POST", HEARTBEAT_URL, body) + + resp = await client.post( + HEARTBEAT_URL, + content=body, + headers={**headers, "Content-Type": "application/json"}, + ) + assert resp.status_code == 200, resp.text + data = resp.json() + assert "server_time" in data + assert "heartbeat_interval_sec" in data + assert data["device_status"] == "approved" + + +async def test_heartbeat_invalid_signature(client: AsyncClient, approved_kiosk_device, kiosk_keypair): + """Heartbeat mit falschem Signing-Key → 401.""" + wrong_key = Ed25519PrivateKey.generate() + device_id = approved_kiosk_device["id"] + body = b'{"queued_offline_entries": 0}' + headers = make_kiosk_headers(device_id, wrong_key, "POST", HEARTBEAT_URL, body) + + resp = await client.post( + HEARTBEAT_URL, + content=body, + headers={**headers, "Content-Type": "application/json"}, + ) + assert resp.status_code == 401, resp.text + + +async def test_heartbeat_replay(client: AsyncClient, approved_kiosk_device, kiosk_keypair): + """Gleiche Nonce zweimal senden → zweiter Request 401 (Replay-Schutz).""" + private_key, _ = kiosk_keypair + device_id = approved_kiosk_device["id"] + body = b'{"queued_offline_entries": 0}' + headers = make_kiosk_headers(device_id, private_key, "POST", HEARTBEAT_URL, body) + merged = {**headers, "Content-Type": "application/json"} + + # Erster Request muss durchgehen + r1 = await client.post(HEARTBEAT_URL, content=body, headers=merged) + assert r1.status_code == 200, r1.text + + # Zweiter Request mit identischen Headern (selbe Nonce) muss abgelehnt werden + r2 = await client.post(HEARTBEAT_URL, content=body, headers=merged) + assert r2.status_code == 401, r2.text + + +async def test_heartbeat_timestamp_drift(client: AsyncClient, approved_kiosk_device, kiosk_keypair): + """Timestamp der mehr als 30 Sekunden zurückliegt → 401.""" + private_key, _ = kiosk_keypair + device_id = approved_kiosk_device["id"] + body = b'{"queued_offline_entries": 0}' + + old_timestamp = str(int(time.time()) - 60) + nonce = str(uuid.uuid4()) + body_hash = hashlib.sha256(body).hexdigest() + message = f"POST {HEARTBEAT_URL} {old_timestamp} {nonce} {body_hash}".encode() + signature = private_key.sign(message) + + headers = { + "X-Kiosk-Key-Id": device_id, + "X-Kiosk-Timestamp": old_timestamp, + "X-Kiosk-Nonce": nonce, + "X-Kiosk-Signature": base64.b64encode(signature).decode(), + "Content-Type": "application/json", + } + resp = await client.post(HEARTBEAT_URL, content=body, headers=headers) + assert resp.status_code == 401, resp.text + + +async def test_heartbeat_pending_device(client: AsyncClient, kiosk_admin_headers, kiosk_keypair): + """Heartbeat von einem Gerät das noch nicht genehmigt wurde → 401.""" + private_key, public_key_pem = kiosk_keypair + + # Neues Gerät anlegen, aber NICHT genehmigen + resp = await client.post( + DEVICES_URL, + json={"name": "Pending-Kiosk", "public_key": public_key_pem}, + headers=kiosk_admin_headers, + ) + assert resp.status_code == 201, resp.text + device_id = resp.json()["id"] + + body = b'{"queued_offline_entries": 0}' + headers = make_kiosk_headers(device_id, private_key, "POST", HEARTBEAT_URL, body) + r = await client.post( + HEARTBEAT_URL, + content=body, + headers={**headers, "Content-Type": "application/json"}, + ) + assert r.status_code == 401, r.text + + +async def test_heartbeat_revoked_device( + client: AsyncClient, kiosk_admin_headers, kiosk_keypair +): + """Heartbeat von einem gesperrten Gerät → 401.""" + private_key, public_key_pem = kiosk_keypair + + # Gerät anlegen, genehmigen und dann sperren + resp = await client.post( + DEVICES_URL, + json={"name": "Revoked-Kiosk", "public_key": public_key_pem}, + headers=kiosk_admin_headers, + ) + assert resp.status_code == 201, resp.text + device_id = resp.json()["id"] + + await client.post(f"{DEVICES_URL}/{device_id}/approve", headers=kiosk_admin_headers) + revoke_resp = await client.post( + f"{DEVICES_URL}/{device_id}/revoke", headers=kiosk_admin_headers + ) + assert revoke_resp.status_code == 200, revoke_resp.text + assert revoke_resp.json()["status"] == "revoked" + + body = b'{"queued_offline_entries": 0}' + headers = make_kiosk_headers(device_id, private_key, "POST", HEARTBEAT_URL, body) + r = await client.post( + HEARTBEAT_URL, + content=body, + headers={**headers, "Content-Type": "application/json"}, + ) + assert r.status_code == 401, r.text + + +async def test_kiosk_device_lifecycle(client: AsyncClient, kiosk_admin_headers, kiosk_keypair): + """Gerät anlegen (pending) → genehmigen (approved) → sperren (revoked).""" + _, public_key_pem = kiosk_keypair + + # Anlegen + r = await client.post( + DEVICES_URL, + json={"name": "Lifecycle-Test", "public_key": public_key_pem}, + headers=kiosk_admin_headers, + ) + assert r.status_code == 201, r.text + dev = r.json() + dev_id = dev["id"] + assert dev["status"] == "pending" + + # Genehmigen + r = await client.post(f"{DEVICES_URL}/{dev_id}/approve", headers=kiosk_admin_headers) + assert r.status_code == 200, r.text + assert r.json()["status"] == "approved" + + # Sperren + r = await client.post(f"{DEVICES_URL}/{dev_id}/revoke", headers=kiosk_admin_headers) + assert r.status_code == 200, r.text + assert r.json()["status"] == "revoked" + + +async def test_heartbeat_missing_headers(client: AsyncClient): + """Heartbeat ohne Kiosk-Header → 422 (FastAPI Validierungsfehler).""" + resp = await client.post( + HEARTBEAT_URL, + json={"queued_offline_entries": 0}, + ) + assert resp.status_code == 422, resp.text + + +async def test_kiosk_devices_list( + client: AsyncClient, kiosk_admin_headers, approved_kiosk_device +): + """Admin kann Geräteliste abrufen und approved_kiosk_device ist enthalten.""" + resp = await client.get(DEVICES_URL, headers=kiosk_admin_headers) + assert resp.status_code == 200, resp.text + devices = resp.json() + assert isinstance(devices, list) + assert len(devices) > 0 + ids = [d["id"] for d in devices] + assert approved_kiosk_device["id"] in ids + # heartbeat_status muss in jedem Eintrag vorhanden sein + assert "heartbeat_status" in devices[0] + + +async def test_kiosk_devices_list_no_auth(client: AsyncClient): + """Geräteliste ohne Auth-Header → 401 oder 403.""" + resp = await client.get(DEVICES_URL) + assert resp.status_code in (401, 403), resp.text + + +async def test_kiosk_device_create_invalid_key(client: AsyncClient, kiosk_admin_headers): + """ + Ungültiger Public Key beim Anlegen. + + Das Backend validiert den Public Key erst bei der ersten signierten Anfrage + (lazy validation in verify_kiosk_request), nicht schon beim Anlegen. + Deshalb wird das Gerät mit Status 'pending' angelegt (201). + Ein Heartbeat mit diesem Gerät würde dann 401 liefern. + """ + resp = await client.post( + DEVICES_URL, + json={"name": "Bad-Key-Kiosk", "public_key": "kein-valid-key"}, + headers=kiosk_admin_headers, + ) + # Backend akzeptiert den Key beim Anlegen (lazy validation) → 201 + assert resp.status_code == 201, resp.text + assert resp.json()["status"] == "pending" + + +async def test_heartbeat_unknown_device(client: AsyncClient, kiosk_keypair): + """Heartbeat mit unbekannter Geräte-ID → 401.""" + private_key, _ = kiosk_keypair + fake_device_id = str(uuid.uuid4()) + body = b'{"queued_offline_entries": 0}' + headers = make_kiosk_headers(fake_device_id, private_key, "POST", HEARTBEAT_URL, body) + + resp = await client.post( + HEARTBEAT_URL, + content=body, + headers={**headers, "Content-Type": "application/json"}, + ) + assert resp.status_code == 401, resp.text + + +async def test_heartbeat_invalid_key_id_format(client: AsyncClient, kiosk_keypair): + """X-Kiosk-Key-Id ist keine gültige UUID → 401.""" + private_key, _ = kiosk_keypair + body = b'{"queued_offline_entries": 0}' + timestamp = str(int(time.time())) + nonce = str(uuid.uuid4()) + body_hash = hashlib.sha256(body).hexdigest() + message = f"POST {HEARTBEAT_URL} {timestamp} {nonce} {body_hash}".encode() + signature = private_key.sign(message) + + headers = { + "X-Kiosk-Key-Id": "nicht-eine-uuid", + "X-Kiosk-Timestamp": timestamp, + "X-Kiosk-Nonce": nonce, + "X-Kiosk-Signature": base64.b64encode(signature).decode(), + "Content-Type": "application/json", + } + resp = await client.post(HEARTBEAT_URL, content=body, headers=headers) + assert resp.status_code == 401, resp.text + + +async def test_heartbeat_future_timestamp(client: AsyncClient, approved_kiosk_device, kiosk_keypair): + """Timestamp weit in der Zukunft (>30s) → 401.""" + private_key, _ = kiosk_keypair + device_id = approved_kiosk_device["id"] + body = b'{"queued_offline_entries": 0}' + + future_timestamp = str(int(time.time()) + 120) + nonce = str(uuid.uuid4()) + body_hash = hashlib.sha256(body).hexdigest() + message = f"POST {HEARTBEAT_URL} {future_timestamp} {nonce} {body_hash}".encode() + signature = private_key.sign(message) + + headers = { + "X-Kiosk-Key-Id": device_id, + "X-Kiosk-Timestamp": future_timestamp, + "X-Kiosk-Nonce": nonce, + "X-Kiosk-Signature": base64.b64encode(signature).decode(), + "Content-Type": "application/json", + } + resp = await client.post(HEARTBEAT_URL, content=body, headers=headers) + assert resp.status_code == 401, resp.text + + +async def test_heartbeat_updates_heartbeat_status( + client: AsyncClient, approved_kiosk_device, kiosk_keypair, kiosk_admin_headers +): + """Nach einem erfolgreichen Heartbeat hat das Gerät heartbeat_status 'online'.""" + private_key, _ = kiosk_keypair + device_id = approved_kiosk_device["id"] + body = b'{"queued_offline_entries": 0}' + headers = make_kiosk_headers(device_id, private_key, "POST", HEARTBEAT_URL, body) + + hb_resp = await client.post( + HEARTBEAT_URL, + content=body, + headers={**headers, "Content-Type": "application/json"}, + ) + assert hb_resp.status_code == 200, hb_resp.text + + # Gerät abrufen und heartbeat_status prüfen + dev_resp = await client.get( + f"{DEVICES_URL}/{device_id}", headers=kiosk_admin_headers + ) + assert dev_resp.status_code == 200, dev_resp.text + assert dev_resp.json()["heartbeat_status"] == "online" diff --git a/frontend/DEVLOG.md b/frontend/DEVLOG.md index a424c0f..396f427 100644 --- a/frontend/DEVLOG.md +++ b/frontend/DEVLOG.md @@ -467,3 +467,23 @@ Keine Commits in dieser Session. - frontend/src/pages/TimeTrackingPage.tsx | 521 +++++++++++++++++++-------- --- +## 2026-05-24 12:13 – 12:14 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- 0f83d13 feat(kiosk): Stufe 2 – Ed25519-Auth, CLI-Tool, neue KioskDevicesPage + +### Geänderte Dateien +- DEVLOG.md | 58 ++++ +- backend/app/core/kiosk_security.py | 233 ++++++++++++++ +- backend/app/routers/kiosk.py | 119 +++++-- +- backend/app/schemas/kiosk.py | 70 ++++- +- backend/app/services/kiosk_service.py | 138 ++++++--- +- backend/cli.py | 529 ++++++++++++++++++++++++++++++++ +- backend/requirements.txt | 2 + +- frontend/DEVLOG.md | 16 + +- frontend/src/components/Layout.tsx | 71 ++++- +- frontend/src/pages/KioskDevicesPage.tsx | 412 +++++++++++++++++-------- + +--- diff --git a/frontend/public/kiosk-sw.js b/frontend/public/kiosk-sw.js new file mode 100644 index 0000000..d349415 --- /dev/null +++ b/frontend/public/kiosk-sw.js @@ -0,0 +1,187 @@ +// TimeMaster Kiosk ServiceWorker +// Signs all /api/v1/kiosk/ requests with Ed25519 credentials stored in IndexedDB. + +const DB_NAME = 'kiosk-credentials'; +const DB_VERSION = 1; +const STORE_NAME = 'credentials'; +const CRED_KEY = 'active'; +const CLIENT_VERSION = '1.0.0'; + +// ── Lifecycle ────────────────────────────────────────────────────────────── + +self.addEventListener('install', (event) => { + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +// ── Fetch interception ───────────────────────────────────────────────────── + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + if (!url.pathname.startsWith('/api/v1/kiosk/')) return; + event.respondWith(signAndFetch(event.request)); +}); + +async function signAndFetch(request) { + const creds = await getCredentials(); + if (!creds) { + // No keypair stored yet – pass through unsigned (e.g. during setup) + return fetch(request); + } + + // Read body bytes for hashing (clone before consuming) + let bodyBytes = new Uint8Array(0); + if (request.body) { + bodyBytes = new Uint8Array(await request.clone().arrayBuffer()); + } + + // SHA-256 of body as lowercase hex + const bodyHashBuffer = await crypto.subtle.digest('SHA-256', bodyBytes); + const bodyHash = Array.from(new Uint8Array(bodyHashBuffer)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + + const timestamp = Math.floor(Date.now() / 1000).toString(); + const nonce = crypto.randomUUID(); + + const url = new URL(request.url); + const path = url.pathname; + const method = request.method.toUpperCase(); + + // Signature message: "METHOD PATH TIMESTAMP NONCE BODYHASH" + const message = `${method} ${path} ${timestamp} ${nonce} ${bodyHash}`; + const messageBytes = new TextEncoder().encode(message); + + const signatureBuffer = await crypto.subtle.sign( + 'Ed25519', + creds.privateKey, + messageBytes + ); + const signature = btoa( + String.fromCharCode(...new Uint8Array(signatureBuffer)) + ); + + const newHeaders = new Headers(request.headers); + newHeaders.set('X-Kiosk-Key-Id', creds.deviceId); + newHeaders.set('X-Kiosk-Timestamp', timestamp); + newHeaders.set('X-Kiosk-Nonce', nonce); + newHeaders.set('X-Kiosk-Signature', signature); + newHeaders.set('X-Kiosk-Client-Version', CLIENT_VERSION); + + const newRequest = new Request(request, { headers: newHeaders }); + return fetch(newRequest); +} + +// ── Message handler ──────────────────────────────────────────────────────── +// Handles messages from the page via MessageChannel. + +self.addEventListener('message', async (event) => { + const { type, deviceId, port } = event.data; + const replyPort = port || (event.ports && event.ports[0]); + + if (type === 'GENERATE_KEYPAIR') { + try { + // Generate non-extractable Ed25519 keypair + const keyPair = await crypto.subtle.generateKey( + 'Ed25519', + false, // non-extractable – private key stays inside SW + ['sign'] + ); + + // Export public key in SPKI format → PEM + const spkiBuffer = await crypto.subtle.exportKey('spki', keyPair.publicKey); + const publicKeyPem = spkiToPem(spkiBuffer); + + // Persist in IndexedDB + await saveCredentials(deviceId, keyPair.privateKey); + + if (replyPort) { + replyPort.postMessage({ success: true, publicKeyPem }); + } + } catch (err) { + if (replyPort) { + replyPort.postMessage({ success: false, error: err.message }); + } + } + return; + } + + if (type === 'CHECK_CREDENTIALS') { + const creds = await getCredentials(); + if (replyPort) { + replyPort.postMessage({ hasCredentials: !!creds, deviceId: creds?.deviceId ?? null }); + } + return; + } + + if (type === 'CLEAR_CREDENTIALS') { + await clearCredentials(); + if (replyPort) { + replyPort.postMessage({ success: true }); + } + return; + } +}); + +// ── IndexedDB helpers ────────────────────────────────────────────────────── + +function openDb() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = (event) => { + const db = event.target.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'key' }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function getCredentials() { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const req = store.get(CRED_KEY); + req.onsuccess = () => { + if (!req.result) { resolve(null); return; } + resolve({ deviceId: req.result.deviceId, privateKey: req.result.privateKey }); + }; + req.onerror = () => reject(req.error); + }); +} + +async function saveCredentials(deviceId, privateKey) { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const req = store.put({ key: CRED_KEY, deviceId, privateKey }); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +async function clearCredentials() { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const req = store.delete(CRED_KEY); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +} + +// ── Utilities ────────────────────────────────────────────────────────────── + +function spkiToPem(spkiBuffer) { + const b64 = btoa(String.fromCharCode(...new Uint8Array(spkiBuffer))); + const lines = b64.match(/.{1,64}/g).join('\n'); + return `-----BEGIN PUBLIC KEY-----\n${lines}\n-----END PUBLIC KEY-----`; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b5dfe19..90171f9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,6 +22,8 @@ import { CompanySettingsPage } from './pages/CompanySettingsPage' import { ProfilePage } from './pages/ProfilePage' import { KioskDevicesPage } from './pages/KioskDevicesPage' import { AuditLogPage } from './pages/AuditLogPage' +import { KioskSetupPage } from './pages/KioskSetupPage' +import { KioskStampPage } from './pages/KioskStampPage' export default function App() { return ( @@ -32,6 +34,8 @@ export default function App() { } /> } /> } /> + } /> + } /> }> } /> } /> diff --git a/frontend/src/pages/KioskSetupPage.tsx b/frontend/src/pages/KioskSetupPage.tsx new file mode 100644 index 0000000..87bd16e --- /dev/null +++ b/frontend/src/pages/KioskSetupPage.tsx @@ -0,0 +1,307 @@ +import { useEffect, useState, useRef } from 'react' +import { useSearchParams, Link } from 'react-router-dom' + +type SetupStep = 'idle' | 'registering-sw' | 'generating' | 'done' | 'error' + +function isBrowserSupported(): boolean { + try { + // Ed25519 requires SubtleCrypto and the algorithm support + // We can only really verify at runtime, but check for the basics here + return ( + typeof crypto !== 'undefined' && + typeof crypto.subtle !== 'undefined' && + typeof crypto.randomUUID === 'function' && + 'serviceWorker' in navigator + ) + } catch { + return false + } +} + +async function sendMessageToSW( + reg: ServiceWorkerRegistration, + message: object +): Promise> { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + channel.port1.onmessage = (e) => resolve(e.data as Record) + channel.port1.onmessageerror = () => reject(new Error('MessageChannel-Fehler')) + const sw = reg.active + if (!sw) { reject(new Error('ServiceWorker nicht aktiv')); return } + sw.postMessage({ ...message, port: channel.port2 }, [channel.port2]) + }) +} + +export function KioskSetupPage() { + const [searchParams] = useSearchParams() + const deviceIdParam = searchParams.get('device_id') ?? '' + + const [deviceId, setDeviceId] = useState(deviceIdParam) + const [step, setStep] = useState('idle') + const [errorMsg, setErrorMsg] = useState(null) + const [publicKeyPem, setPublicKeyPem] = useState(null) + const [copied, setCopied] = useState(false) + const [existingDeviceId, setExistingDeviceId] = useState(null) + const swRegRef = useRef(null) + + const supported = isBrowserSupported() + + // On mount: register SW and check for existing credentials + useEffect(() => { + if (!supported) return + ;(async () => { + try { + const reg = await navigator.serviceWorker.register('/kiosk-sw.js', { scope: '/' }) + // Wait until the SW is active + if (!reg.active) { + await new Promise((resolve) => { + const sw = reg.installing || reg.waiting + if (!sw) { resolve(); return } + sw.addEventListener('statechange', function handler() { + if (sw.state === 'activated') { + sw.removeEventListener('statechange', handler) + resolve() + } + }) + }) + // Re-fetch after activation + const updated = await navigator.serviceWorker.ready + swRegRef.current = updated + } else { + swRegRef.current = reg + } + // Check if credentials already exist + const ready = await navigator.serviceWorker.ready + swRegRef.current = ready + const result = await sendMessageToSW(ready, { type: 'CHECK_CREDENTIALS' }) + if (result.hasCredentials) { + setExistingDeviceId(result.deviceId as string) + } + } catch { + // SW registration failure is non-fatal for the setup UI + } + })() + }, [supported]) + + async function handleGenerate() { + setErrorMsg(null) + setCopied(false) + setPublicKeyPem(null) + + if (!deviceId.trim()) { + setErrorMsg('Bitte eine Geraete-ID eingeben (UUID des Kiosk-Geraets).') + return + } + + setStep('registering-sw') + try { + if (!swRegRef.current) { + const reg = await navigator.serviceWorker.register('/kiosk-sw.js', { scope: '/' }) + const ready = await navigator.serviceWorker.ready + swRegRef.current = reg.active ? reg : ready + } + const ready = await navigator.serviceWorker.ready + swRegRef.current = ready + + setStep('generating') + const result = await sendMessageToSW(ready, { + type: 'GENERATE_KEYPAIR', + deviceId: deviceId.trim(), + }) + + if (!result.success) { + throw new Error((result.error as string) ?? 'Unbekannter Fehler im ServiceWorker') + } + + setPublicKeyPem(result.publicKeyPem as string) + setExistingDeviceId(deviceId.trim()) + setStep('done') + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e) + // Ed25519 not supported + if (msg.includes('Ed25519') || msg.includes('NotSupportedError') || msg.includes('algorithm')) { + setErrorMsg( + 'Dieser Browser unterstuetzt kein Ed25519. Bitte Chrome 113+, Firefox 130+ oder Safari 16.4+ verwenden.' + ) + } else { + setErrorMsg(msg) + } + setStep('error') + } + } + + async function handleReset() { + if (!swRegRef.current) return + try { + await sendMessageToSW(swRegRef.current, { type: 'CLEAR_CREDENTIALS' }) + setExistingDeviceId(null) + setPublicKeyPem(null) + setStep('idle') + setDeviceId(deviceIdParam) + setErrorMsg(null) + setCopied(false) + } catch (e: unknown) { + setErrorMsg(e instanceof Error ? e.message : 'Fehler beim Zuruecksetzen') + } + } + + async function handleCopy() { + if (!publicKeyPem) return + try { + await navigator.clipboard.writeText(publicKeyPem) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // fallback: select textarea + } + } + + const isGenerating = step === 'registering-sw' || step === 'generating' + + if (!supported) { + return ( +
+
+
⚠️
+

Browser nicht unterstuetzt

+

+ Dieser Browser unterstuetzt kein Ed25519 oder ServiceWorker. + Bitte Chrome 113+, Firefox 130+ oder Safari 16.4+ verwenden. +

+
+
+ ) + } + + return ( +
+
+ + {/* Header */} +
+
🔐
+

Kiosk einrichten

+

+ Generiert ein Ed25519-Schluessel­paar fuer dieses Geraet. + Der private Schluessel verlässt den Browser nie. +

+
+ + {/* Existing credentials warning */} + {existingDeviceId && step !== 'done' && ( +
+ Achtung: Dieses Geraet hat bereits gespeicherte Credentials + fuer Geraet {existingDeviceId}. + Ein Neugenerieren ueberschreibt den vorhandenen Schluessel.{' '} + +
+ )} + + {/* Main card */} +
+ + + {errorMsg && ( +
+ {errorMsg} +
+ )} + + {step !== 'done' && ( + + )} + + {/* Success state */} + {step === 'done' && publicKeyPem && ( +
+
+ + Schluesselpaar generiert und gespeichert. +
+ +
+
+ + Public Key (PEM-Format) + + +
+
+                  {publicKeyPem}
+                
+
+ +
+

Naechste Schritte:

+
    +
  1. Public Key oben kopieren und an den Admin schicken (E-Mail reicht – kein Geheimnis).
  2. +
  3. + Admin registriert das Geraet auf dem Server:{' '} + timemaster kiosk add --pubkey ... +
  4. +
  5. Admin gibt das Geraet in der Web-Oberflaeche frei.
  6. +
  7. Kiosk-Modus starten.
  8. +
+
+ +
+ + Zum Kiosk → + + +
+
+ )} +
+ + {/* Back link */} + {step !== 'done' && ( +

+ Zurueck zum Login +

+ )} +
+
+ ) +} diff --git a/frontend/src/pages/KioskStampPage.tsx b/frontend/src/pages/KioskStampPage.tsx new file mode 100644 index 0000000..52c5648 --- /dev/null +++ b/frontend/src/pages/KioskStampPage.tsx @@ -0,0 +1,348 @@ +import { useEffect, useRef, useState } from 'react' +import { Link } from 'react-router-dom' + +interface HeartbeatResponse { + server_time?: string + server_timestamp?: number + status?: string +} + +// BroadcastChannel name – only one tab sends heartbeats +const HEARTBEAT_CHANNEL = 'kiosk-heartbeat' +const HEARTBEAT_INTERVAL_MS = 30_000 +const CLIENT_VERSION = '1.0.0' + +function formatTime(date: Date): string { + return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) +} + +function formatDate(date: Date): string { + return date.toLocaleDateString('de-DE', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }) +} + +async function sendMessageToSW( + reg: ServiceWorkerRegistration, + message: object +): Promise> { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + channel.port1.onmessage = (e) => resolve(e.data as Record) + channel.port1.onmessageerror = () => reject(new Error('MessageChannel-Fehler')) + const sw = reg.active + if (!sw) { reject(new Error('ServiceWorker nicht aktiv')); return } + sw.postMessage({ ...message, port: channel.port2 }, [channel.port2]) + }) +} + +export function KioskStampPage() { + const [displayTime, setDisplayTime] = useState(new Date()) + const [serverTimeOffset, setServerTimeOffset] = useState(0) // ms offset from server + const [isOnline, setIsOnline] = useState(navigator.onLine) + const [heartbeatStatus, setHeartbeatStatus] = useState<'connected' | 'error' | 'pending'>('pending') + const [heartbeatError, setHeartbeatError] = useState(null) + const [hasCredentials, setHasCredentials] = useState(null) + const [deviceId, setDeviceId] = useState(null) + const [isLeaderTab, setIsLeaderTab] = useState(false) + + const heartbeatIntervalRef = useRef | null>(null) + const clockIntervalRef = useRef | null>(null) + const broadcastRef = useRef(null) + const swRegRef = useRef(null) + const startTimeRef = useRef(Date.now()) + + // Live clock – uses server time offset + useEffect(() => { + clockIntervalRef.current = setInterval(() => { + const localNow = Date.now() + setDisplayTime(new Date(localNow + serverTimeOffset)) + }, 1000) + return () => { + if (clockIntervalRef.current) clearInterval(clockIntervalRef.current) + } + }, [serverTimeOffset]) + + // Online/Offline events + useEffect(() => { + const onOnline = () => setIsOnline(true) + const onOffline = () => setIsOnline(false) + window.addEventListener('online', onOnline) + window.addEventListener('offline', onOffline) + return () => { + window.removeEventListener('online', onOnline) + window.removeEventListener('offline', onOffline) + } + }, []) + + // ServiceWorker + BroadcastChannel setup + useEffect(() => { + let cancelled = false + + async function init() { + // Register / retrieve SW + if ('serviceWorker' in navigator) { + try { + await navigator.serviceWorker.register('/kiosk-sw.js', { scope: '/' }) + const ready = await navigator.serviceWorker.ready + swRegRef.current = ready + + if (!cancelled) { + const result = await sendMessageToSW(ready, { type: 'CHECK_CREDENTIALS' }) + setHasCredentials(!!result.hasCredentials) + setDeviceId(result.deviceId as string | null) + } + } catch { + if (!cancelled) { + setHasCredentials(false) + } + } + } else { + setHasCredentials(false) + } + + if (cancelled) return + + // Leader-election via BroadcastChannel: + // We announce ourselves; if we receive an announcement from another tab + // we yield. Simple "last writer wins for 1s window" approach. + const channel = new BroadcastChannel(HEARTBEAT_CHANNEL) + broadcastRef.current = channel + + let isLeader = true + + channel.onmessage = (e) => { + if (e.data?.type === 'HEARTBEAT_LEADER_ANNOUNCE') { + // Another tab claims leadership – we yield + isLeader = false + setIsLeaderTab(false) + if (heartbeatIntervalRef.current) { + clearInterval(heartbeatIntervalRef.current) + heartbeatIntervalRef.current = null + } + } + if (e.data?.type === 'HEARTBEAT_LEADER_YIELD' && !isLeader) { + // Previous leader stepped down – we take over + isLeader = true + setIsLeaderTab(true) + startHeartbeatLoop() + } + } + + // Announce ourselves as leader after a short random delay + // to avoid simultaneous announcements on page reload + const delay = Math.random() * 500 + await new Promise(r => setTimeout(r, delay)) + if (!cancelled) { + channel.postMessage({ type: 'HEARTBEAT_LEADER_ANNOUNCE' }) + setIsLeaderTab(isLeader) + if (isLeader) startHeartbeatLoop() + } + } + + init() + + return () => { + cancelled = true + if (heartbeatIntervalRef.current) clearInterval(heartbeatIntervalRef.current) + if (broadcastRef.current) { + broadcastRef.current.postMessage({ type: 'HEARTBEAT_LEADER_YIELD' }) + broadcastRef.current.close() + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + function startHeartbeatLoop() { + // Send immediately, then on interval + sendHeartbeat() + if (heartbeatIntervalRef.current) clearInterval(heartbeatIntervalRef.current) + heartbeatIntervalRef.current = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS) + } + + async function sendHeartbeat() { + if (!navigator.onLine) { + setHeartbeatStatus('error') + setHeartbeatError('Kein Netzwerk') + return + } + + const uptimeSeconds = Math.floor((Date.now() - startTimeRef.current) / 1000) + const body = JSON.stringify({ + uptime_seconds: uptimeSeconds, + client_version: CLIENT_VERSION, + queued_offline_entries: 0, + current_user_id: null, + }) + + try { + const res = await fetch('/api/v1/kiosk/heartbeat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }) + + if (!res.ok) { + const errData = await res.json().catch(() => ({ detail: res.statusText })) + throw new Error( + typeof errData.detail === 'string' ? errData.detail : res.statusText + ) + } + + const data: HeartbeatResponse = await res.json().catch(() => ({})) + + // Sync server time if provided + if (data.server_timestamp) { + const localNow = Date.now() + const serverMs = data.server_timestamp * 1000 + setServerTimeOffset(serverMs - localNow) + } else if (data.server_time) { + const localNow = Date.now() + const serverMs = new Date(data.server_time).getTime() + if (!isNaN(serverMs)) { + setServerTimeOffset(serverMs - localNow) + } + } + + setHeartbeatStatus('connected') + setHeartbeatError(null) + } catch (e: unknown) { + setHeartbeatStatus('error') + setHeartbeatError(e instanceof Error ? e.message : 'Verbindungsfehler') + } + } + + return ( +
+ + {/* Status bar */} +
+
+ TimeMaster Kiosk + {deviceId && ( + + {deviceId.slice(0, 8)}… + + )} +
+ +
+ {/* Heartbeat / connection status */} + {isLeaderTab && ( +
+ {heartbeatStatus === 'connected' && ( + <> + + Verbunden + + )} + {heartbeatStatus === 'error' && ( + <> + + {heartbeatError ?? 'Verbindungsfehler'} + + )} + {heartbeatStatus === 'pending' && ( + <> + + Verbinde… + + )} +
+ )} + {!isLeaderTab && ( + Heartbeat: anderer Tab aktiv + )} + + {/* Online/Offline indicator */} +
+ {isOnline + ? Online + : Offline + } +
+
+
+ + {/* Main content */} +
+ + {/* Clock */} +
+
+ {formatTime(displayTime)} +
+
+ {formatDate(displayTime)} +
+ {Math.abs(serverTimeOffset) > 1000 && ( +
+ Server-Zeit-Offset: {serverTimeOffset > 0 ? '+' : ''}{Math.round(serverTimeOffset / 1000)}s +
+ )} +
+ + {/* Credentials missing warning */} + {hasCredentials === false && ( +
+
⚠️
+

Kiosk nicht eingerichtet

+

+ Dieses Geraet hat noch kein Ed25519-Schluesselpaar. + Bitte zuerst den Setup-Assistenten durchlaufen. +

+ + Kiosk einrichten → + +
+ )} + + {/* Kiosk active state */} + {hasCredentials === true && ( +
+
+

Kiosk-Modus aktiv

+

+ Alle Stempel-Anfragen werden automatisch per Ed25519 signiert. +

+ + {heartbeatStatus === 'error' && heartbeatError && ( +
+ Verbindungsfehler: {heartbeatError} +
+ )} + + +
+ )} + + {/* Loading state */} + {hasCredentials === null && ( +
Initialisiere…
+ )} +
+ + {/* Footer */} +
+ + Kiosk-Einrichtung + +
+
+ ) +}