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
+187
View File
@@ -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-----`;
}