Files
patrick 35fcea90f4 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>
2026-05-24 12:23:03 +02:00

188 lines
6.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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-----`;
}