// 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-----`; }