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:
@@ -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-----`;
|
||||
}
|
||||
Reference in New Issue
Block a user