feat(kiosk): Stufe 3 – ServiceWorker, WebCrypto Setup-Flow, Kiosk-UI, 15 Security-Tests

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 12:23:03 +02:00
parent 0f83d13c0c
commit 35fcea90f4
7 changed files with 1273 additions and 0 deletions
+4
View File
@@ -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() {
<Route path='/register' element={<RegisterPage />} />
<Route path='/forgot-password' element={<ForgotPasswordPage />} />
<Route path='/auth/reset-password' element={<ResetPasswordPage />} />
<Route path='/kiosk/setup' element={<KioskSetupPage />} />
<Route path='/kiosk' element={<KioskStampPage />} />
<Route element={<ProtectedRoute />}>
<Route path='/dashboard' element={<DashboardPage />} />
<Route path='/time' element={<TimeTrackingPage />} />
+307
View File
@@ -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<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (e) => resolve(e.data as Record<string, unknown>)
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<SetupStep>('idle')
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [publicKeyPem, setPublicKeyPem] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [existingDeviceId, setExistingDeviceId] = useState<string | null>(null)
const swRegRef = useRef<ServiceWorkerRegistration | null>(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<void>((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 (
<div className='min-h-screen bg-gray-900 text-white flex items-center justify-center p-6'>
<div className='max-w-md w-full bg-gray-800 rounded-2xl p-8 text-center'>
<div className='text-4xl mb-4'></div>
<h1 className='text-xl font-bold mb-2'>Browser nicht unterstuetzt</h1>
<p className='text-gray-400 text-sm'>
Dieser Browser unterstuetzt kein Ed25519 oder ServiceWorker.
Bitte Chrome 113+, Firefox 130+ oder Safari 16.4+ verwenden.
</p>
</div>
</div>
)
}
return (
<div className='min-h-screen bg-gray-900 text-white flex items-center justify-center p-6'>
<div className='max-w-lg w-full space-y-6'>
{/* Header */}
<div className='text-center'>
<div className='text-5xl mb-3'>🔐</div>
<h1 className='text-2xl font-bold'>Kiosk einrichten</h1>
<p className='text-gray-400 text-sm mt-1'>
Generiert ein Ed25519-Schluessel&shy;paar fuer dieses Geraet.
Der private Schluessel verlässt den Browser nie.
</p>
</div>
{/* Existing credentials warning */}
{existingDeviceId && step !== 'done' && (
<div className='bg-yellow-900/50 border border-yellow-700 rounded-xl px-4 py-3 text-sm text-yellow-300'>
<span className='font-semibold'>Achtung:</span> Dieses Geraet hat bereits gespeicherte Credentials
fuer Geraet <code className='bg-yellow-900 rounded px-1'>{existingDeviceId}</code>.
Ein Neugenerieren ueberschreibt den vorhandenen Schluessel.{' '}
<button onClick={handleReset} className='underline hover:text-yellow-100 ml-1'>
Zuruecksetzen
</button>
</div>
)}
{/* Main card */}
<div className='bg-gray-800 rounded-2xl p-6 space-y-4'>
<label className='block'>
<span className='text-sm font-medium text-gray-300 block mb-1'>Geraete-ID (UUID)</span>
<input
value={deviceId}
onChange={e => setDeviceId(e.target.value)}
placeholder='z.B. 550e8400-e29b-41d4-a716-446655440000'
disabled={isGenerating || step === 'done'}
className='w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-sm text-white
placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500
disabled:opacity-50'
/>
<p className='text-xs text-gray-500 mt-1'>
Die UUID des Geraets aus der Admin-Oberflaeche (Einstellungen Kiosk-Geraete).
</p>
</label>
{errorMsg && (
<div className='bg-red-900/50 border border-red-700 rounded-lg px-3 py-2 text-sm text-red-300'>
{errorMsg}
</div>
)}
{step !== 'done' && (
<button
onClick={handleGenerate}
disabled={isGenerating || !deviceId.trim()}
className='w-full py-3 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed
rounded-xl font-semibold text-sm transition-colors'
>
{step === 'registering-sw' && 'ServiceWorker wird registriert…'}
{step === 'generating' && 'Schluesselpaar wird generiert…'}
{(step === 'idle' || step === 'error') && 'Schluesselpaar generieren'}
</button>
)}
{/* Success state */}
{step === 'done' && publicKeyPem && (
<div className='space-y-4'>
<div className='flex items-center gap-2 text-green-400 text-sm font-medium'>
<span className='text-lg'></span>
Schluesselpaar generiert und gespeichert.
</div>
<div>
<div className='flex items-center justify-between mb-1'>
<span className='text-xs font-medium text-gray-400 uppercase tracking-wide'>
Public Key (PEM-Format)
</span>
<button
onClick={handleCopy}
className='text-xs text-blue-400 hover:text-blue-300 transition-colors'
>
{copied ? '✓ Kopiert!' : 'Kopieren'}
</button>
</div>
<pre className='bg-gray-900 rounded-lg p-3 text-xs text-green-300 font-mono
overflow-x-auto whitespace-pre-wrap break-all border border-gray-700'>
{publicKeyPem}
</pre>
</div>
<div className='bg-blue-900/40 border border-blue-700 rounded-lg px-3 py-3 text-xs text-blue-300 space-y-1'>
<p className='font-semibold text-blue-200'>Naechste Schritte:</p>
<ol className='list-decimal ml-4 space-y-1'>
<li>Public Key oben kopieren und an den Admin schicken (E-Mail reicht kein Geheimnis).</li>
<li>
Admin registriert das Geraet auf dem Server:{' '}
<code className='bg-blue-950 rounded px-1'>timemaster kiosk add --pubkey ...</code>
</li>
<li>Admin gibt das Geraet in der Web-Oberflaeche frei.</li>
<li>Kiosk-Modus starten.</li>
</ol>
</div>
<div className='flex gap-3'>
<Link
to='/kiosk'
className='flex-1 text-center py-2.5 bg-green-600 hover:bg-green-700 rounded-xl
font-semibold text-sm transition-colors'
>
Zum Kiosk
</Link>
<button
onClick={handleReset}
className='px-4 py-2.5 border border-gray-600 text-gray-300 hover:bg-gray-700
rounded-xl text-sm transition-colors'
>
Zuruecksetzen
</button>
</div>
</div>
)}
</div>
{/* Back link */}
{step !== 'done' && (
<p className='text-center text-xs text-gray-600'>
<Link to='/login' className='hover:text-gray-400 underline'>Zurueck zum Login</Link>
</p>
)}
</div>
</div>
)
}
+348
View File
@@ -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<Record<string, unknown>> {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (e) => resolve(e.data as Record<string, unknown>)
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<string | null>(null)
const [hasCredentials, setHasCredentials] = useState<boolean | null>(null)
const [deviceId, setDeviceId] = useState<string | null>(null)
const [isLeaderTab, setIsLeaderTab] = useState(false)
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const clockIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const broadcastRef = useRef<BroadcastChannel | null>(null)
const swRegRef = useRef<ServiceWorkerRegistration | null>(null)
const startTimeRef = useRef<number>(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 (
<div className='min-h-screen bg-gray-950 text-white flex flex-col'>
{/* Status bar */}
<div className='flex items-center justify-between px-6 py-3 bg-gray-900 border-b border-gray-800'>
<div className='flex items-center gap-3'>
<span className='text-sm font-semibold text-gray-300'>TimeMaster Kiosk</span>
{deviceId && (
<span className='text-xs text-gray-600 font-mono'>
{deviceId.slice(0, 8)}
</span>
)}
</div>
<div className='flex items-center gap-4'>
{/* Heartbeat / connection status */}
{isLeaderTab && (
<div className='flex items-center gap-1.5 text-xs'>
{heartbeatStatus === 'connected' && (
<>
<span className='w-2 h-2 rounded-full bg-green-500 animate-pulse' />
<span className='text-green-400'>Verbunden</span>
</>
)}
{heartbeatStatus === 'error' && (
<>
<span className='w-2 h-2 rounded-full bg-red-500' />
<span className='text-red-400'>{heartbeatError ?? 'Verbindungsfehler'}</span>
</>
)}
{heartbeatStatus === 'pending' && (
<>
<span className='w-2 h-2 rounded-full bg-yellow-500 animate-pulse' />
<span className='text-yellow-400'>Verbinde</span>
</>
)}
</div>
)}
{!isLeaderTab && (
<span className='text-xs text-gray-600'>Heartbeat: anderer Tab aktiv</span>
)}
{/* Online/Offline indicator */}
<div className='flex items-center gap-1.5 text-xs'>
{isOnline
? <span className='text-gray-500'>Online</span>
: <span className='text-orange-400 font-semibold'>Offline</span>
}
</div>
</div>
</div>
{/* Main content */}
<div className='flex-1 flex flex-col items-center justify-center gap-8 px-6'>
{/* Clock */}
<div className='text-center'>
<div className='text-8xl font-bold font-mono tracking-tight tabular-nums'>
{formatTime(displayTime)}
</div>
<div className='text-xl text-gray-400 mt-2'>
{formatDate(displayTime)}
</div>
{Math.abs(serverTimeOffset) > 1000 && (
<div className='text-xs text-yellow-600 mt-1'>
Server-Zeit-Offset: {serverTimeOffset > 0 ? '+' : ''}{Math.round(serverTimeOffset / 1000)}s
</div>
)}
</div>
{/* Credentials missing warning */}
{hasCredentials === false && (
<div className='max-w-md w-full bg-yellow-900/50 border border-yellow-700 rounded-2xl p-5 text-center'>
<div className='text-3xl mb-2'></div>
<p className='text-yellow-300 font-semibold mb-1'>Kiosk nicht eingerichtet</p>
<p className='text-yellow-500 text-sm mb-4'>
Dieses Geraet hat noch kein Ed25519-Schluesselpaar.
Bitte zuerst den Setup-Assistenten durchlaufen.
</p>
<Link
to='/kiosk/setup'
className='inline-block px-5 py-2.5 bg-yellow-600 hover:bg-yellow-500 rounded-xl
font-semibold text-sm transition-colors'
>
Kiosk einrichten
</Link>
</div>
)}
{/* Kiosk active state */}
{hasCredentials === true && (
<div className='max-w-md w-full bg-gray-800 rounded-2xl p-6 text-center space-y-4'>
<div className='text-4xl'></div>
<p className='text-green-400 font-semibold text-lg'>Kiosk-Modus aktiv</p>
<p className='text-gray-400 text-sm'>
Alle Stempel-Anfragen werden automatisch per Ed25519 signiert.
</p>
{heartbeatStatus === 'error' && heartbeatError && (
<div className='bg-red-900/40 border border-red-700 rounded-lg px-3 py-2 text-sm text-red-300'>
Verbindungsfehler: {heartbeatError}
</div>
)}
<button
onClick={sendHeartbeat}
className='text-xs text-gray-500 hover:text-gray-300 underline transition-colors'
>
Heartbeat manuell senden
</button>
</div>
)}
{/* Loading state */}
{hasCredentials === null && (
<div className='text-gray-600 text-sm animate-pulse'>Initialisiere</div>
)}
</div>
{/* Footer */}
<div className='px-6 py-4 text-center'>
<Link
to='/kiosk/setup'
className='text-xs text-gray-700 hover:text-gray-500 transition-colors underline'
>
Kiosk-Einrichtung
</Link>
</div>
</div>
)
}