(function () { 'use strict'; /* ========================= AUTH LOCAL (INVITÉ / PREMIUM) — SANS SERVEUR - Invité: accès limité (ex: pas de personnalisation du bouton crise) - Premium: accès complet (déverrouillage via mot de passe local) - Sécurité: PBKDF2 + AES-GCM, stockage IndexedDB ========================= */ const AUTH_DB_NAME = 'EPI_AUTH_DB'; const AUTH_DB_STORE = 'auth'; const AUTH_DB_VERSION = 1; const AUTH_CFG = { pbkdf2Iterations: 210000, saltBytes: 16, aesKeyBits: 256, sessionIdleMinutes: 10, }; let AUTH_STATE = { role: 'guest', // 'guest' | 'premium' unlockedAt: 0, passKey: null, // en mémoire masterKey: null // en mémoire }; // --- Style injecté (pour .locked + séparateur “— — —”) --- (function injectAuthGuestStyles() { try { if (document.getElementById('authGuestStyles')) return; const css = ` .locked { opacity:.55; filter: grayscale(.2); } .menu-sep-guest { margin:10px 0; padding:6px 10px; border-radius:10px; opacity:.55; font-size:12px; letter-spacing:.2em; } .locked-hint { font-size:12px; opacity:.85; margin-top:6px; } `; const style = document.createElement('style'); style.id = 'authGuestStyles'; style.textContent = css; document.head.appendChild(style); } catch(e) {} })(); // ---------- IndexedDB helpers ---------- function authOpenDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(AUTH_DB_NAME, AUTH_DB_VERSION); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(AUTH_DB_STORE)) { db.createObjectStore(AUTH_DB_STORE, { keyPath: 'id' }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function authDBGet(id) { const db = await authOpenDB(); return new Promise((resolve, reject) => { const tx = db.transaction(AUTH_DB_STORE, 'readonly'); const st = tx.objectStore(AUTH_DB_STORE); const req = st.get(id); req.onsuccess = () => resolve(req.result || null); req.onerror = () => reject(req.error); }); } async function authDBPut(obj) { const db = await authOpenDB(); return new Promise((resolve, reject) => { const tx = db.transaction(AUTH_DB_STORE, 'readwrite'); const st = tx.objectStore(AUTH_DB_STORE); const req = st.put(obj); req.onsuccess = () => resolve(true); req.onerror = () => reject(req.error); }); } // ---------- WebCrypto helpers ---------- function bufToB64(buf) { const bytes = new Uint8Array(buf); let binary = ''; for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); return btoa(binary); } function b64ToBuf(b64) { const binary = atob(b64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes.buffer; } function strToBuf(s) { return new TextEncoder().encode(String(s)).buffer; } function bufToStr(buf) { return new TextDecoder().decode(new Uint8Array(buf)); } function randomBytes(n) { const a = new Uint8Array(n); crypto.getRandomValues(a); return a.buffer; } async function pbkdf2KeyFromPassword(password, saltBuf, iterations) { const baseKey = await crypto.subtle.importKey('raw', strToBuf(password), 'PBKDF2', false, ['deriveKey']); return crypto.subtle.deriveKey( { name: 'PBKDF2', salt: new Uint8Array(saltBuf), iterations, hash: 'SHA-256' }, baseKey, { name: 'AES-GCM', length: AUTH_CFG.aesKeyBits }, false, ['encrypt', 'decrypt'] ); } async function aesGcmEncrypt(key, plaintextBuf) { const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintextBuf); return { ivB64: bufToB64(iv.buffer), ctB64: bufToB64(ct) }; } async function aesGcmDecrypt(key, ivB64, ctB64) { const iv = new Uint8Array(b64ToBuf(ivB64)); const ct = b64ToBuf(ctB64); return crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct); } // ---------- Auth core + SAFE (token 2 appareils optionnel, ne bloque jamais) ---------- // (1) Option 2 appareils (token) : activable sans bloquer l’app const PREMIUM_TOKEN_ID = 'premium_token_v1'; const DEVICE_ID_KEY = 'epi_device_id_v1'; const MAX_DEVICES = 2; // Base64 safe helpers (pas de escape/unescape => moins d’erreurs) function _toB64FromBuf(buf) { const bytes = new Uint8Array(buf); let s = ''; for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); return btoa(s); } function _toBufFromB64(b64) { const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes.buffer; } function _b64JsonEncode(obj) { const json = JSON.stringify(obj); const buf = new TextEncoder().encode(json).buffer; return _toB64FromBuf(buf); } function _b64JsonDecode(b64) { const buf = _toBufFromB64(b64); const json = new TextDecoder().decode(new Uint8Array(buf)); return JSON.parse(json); } // Device ID local (stable) async function getOrCreateDeviceId() { try { const existing = localStorage.getItem(DEVICE_ID_KEY); if (existing) return existing; } catch (e) {} const id = (crypto.randomUUID ? crypto.randomUUID() : ('dev-' + Math.random().toString(16).slice(2) + Date.now())); try { localStorage.setItem(DEVICE_ID_KEY, id); } catch (e) {} return id; } // --- Token crypto --- // ⚠️ Tout est dans try/catch au moment de l’usage : aucun impact sur le démarrage async function _deriveTokenKey(password, saltBuf, iterations) { const baseKey = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(String(password)), 'PBKDF2', false, ['deriveKey'] ); return crypto.subtle.deriveKey( { name: 'PBKDF2', salt: new Uint8Array(saltBuf), iterations, hash: 'SHA-256' }, baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] ); } async function _encTokenWithPwd(password, payloadObj) { const salt = crypto.getRandomValues(new Uint8Array(16)); const iterations = (AUTH_CFG && AUTH_CFG.pbkdf2Iterations) ? AUTH_CFG.pbkdf2Iterations : 210000; const key = await _deriveTokenKey(password, salt.buffer, iterations); const iv = crypto.getRandomValues(new Uint8Array(12)); const pt = new TextEncoder().encode(JSON.stringify(payloadObj)).buffer; const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, pt); // token = base64(JSON) return _b64JsonEncode({ v: 1, saltB64: _toB64FromBuf(salt.buffer), iter: iterations, ivB64: _toB64FromBuf(iv.buffer), ctB64: _toB64FromBuf(ct) }); } async function _decTokenWithPwd(password, tokenStr) { const tokenObj = _b64JsonDecode(tokenStr); if (!tokenObj || tokenObj.v !== 1) throw new Error('Token invalide.'); const saltBuf = _toBufFromB64(tokenObj.saltB64); const iv = new Uint8Array(_toBufFromB64(tokenObj.ivB64)); const ct = _toBufFromB64(tokenObj.ctB64); const iterations = tokenObj.iter || 210000; const key = await _deriveTokenKey(password, saltBuf, iterations); const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct); return JSON.parse(new TextDecoder().decode(new Uint8Array(pt))); } // Créer/maj token sur appareil (ne bloque jamais si erreur) async function createOrUpdatePremiumToken(password, deviceName = '') { const myId = await getOrCreateDeviceId(); const name = (deviceName || navigator.userAgent || 'Appareil').slice(0, 60); let payload = null; try { const saved = localStorage.getItem(PREMIUM_TOKEN_ID); if (saved) payload = await _decTokenWithPwd(password, saved); } catch (e) { payload = null; // token cassé => on repart propre } if (!payload) payload = { v: 1, createdAt: Date.now(), devices: [] }; if (!Array.isArray(payload.devices)) payload.devices = []; if (!payload.devices.some(d => d.id === myId)) { if (payload.devices.length >= MAX_DEVICES) { throw new Error(`Limite ${MAX_DEVICES} appareils atteinte. Révoque un appareil pour en ajouter un autre.`); } payload.devices.push({ id: myId, name, addedAt: Date.now() }); } const tokenStr = await _encTokenWithPwd(password, payload); try { localStorage.setItem(PREMIUM_TOKEN_ID, tokenStr); } catch (e) {} return { tokenStr, payload }; } // Import token sur 2e appareil async function importPremiumToken(password, tokenStr, deviceName = '') { const myId = await getOrCreateDeviceId(); const name = (deviceName || navigator.userAgent || 'Appareil').slice(0, 60); const payload = await _decTokenWithPwd(password, tokenStr); if (!Array.isArray(payload.devices)) payload.devices = []; if (!payload.devices.some(d => d.id === myId)) { if (payload.devices.length >= MAX_DEVICES) { const list = payload.devices.map(d => `• ${d.name || d.id}`).join('\n'); throw new Error(`Limite ${MAX_DEVICES} appareils atteinte.\nAppareils actuels:\n${list}`); } payload.devices.push({ id: myId, name, addedAt: Date.now() }); } const updatedTokenStr = await _encTokenWithPwd(password, payload); try { localStorage.setItem(PREMIUM_TOKEN_ID, updatedTokenStr); } catch (e) {} return { updatedTokenStr, payload }; } // Vérifie autorisation device via token (si token existe) // 🔒 SAFE: si token illisible => on n’empêche pas la connexion (on évite de bloquer l’app) async function tokenAllowsThisDevice(password) { let saved = null; try { saved = localStorage.getItem(PREMIUM_TOKEN_ID); } catch (e) { saved = null; } if (!saved) return true; try { const payload = await _decTokenWithPwd(password, saved); const myId = await getOrCreateDeviceId(); return Array.isArray(payload.devices) && payload.devices.some(d => d.id === myId); } catch (e) { return true; // token cassé => ne bloque pas l’app } } // ---------- Auth core ---------- async function authHasPremiumSetup() { const rec = await authDBGet('premium_v1'); return !!rec; } async function authSetupPremium(password) { if (!password || String(password).length < 6) throw new Error('Mot de passe trop court (min 6).'); // (optionnel) créer/maj token — NE BLOQUE PAS la création si erreur try { await createOrUpdatePremiumToken(password, 'Appareil 1'); } catch (e) {} const saltBuf = randomBytes(AUTH_CFG.saltBytes); const iterations = AUTH_CFG.pbkdf2Iterations; const passKey = await pbkdf2KeyFromPassword(password, saltBuf, iterations); const masterKey = await crypto.subtle.generateKey( { name: 'AES-GCM', length: AUTH_CFG.aesKeyBits }, true, ['encrypt','decrypt'] ); const verifier = await aesGcmEncrypt(passKey, strToBuf('OK')); const rawMaster = await crypto.subtle.exportKey('raw', masterKey); const encMasterKey = await aesGcmEncrypt(passKey, rawMaster); await authDBPut({ id: 'premium_v1', version: 1, saltB64: bufToB64(saltBuf), iterations, verifier, encMasterKey }); AUTH_STATE.role = 'premium'; AUTH_STATE.passKey = passKey; AUTH_STATE.masterKey = masterKey; AUTH_STATE.unlockedAt = Date.now(); authUpdateHeaderUI(); } async function authLoginPremium(password) { // (optionnel) token 2 appareils : si token existe et interdit => bloquer const allowed = await tokenAllowsThisDevice(password); if (!allowed) throw new Error(`Cet appareil n’est pas autorisé (max ${MAX_DEVICES}).`); const rec = await authDBGet('premium_v1'); if (!rec) throw new Error('Aucun premium configuré sur cet appareil.'); const saltBuf = b64ToBuf(rec.saltB64); const passKey = await pbkdf2KeyFromPassword(password, saltBuf, rec.iterations || AUTH_CFG.pbkdf2Iterations); try { const pt = await aesGcmDecrypt(passKey, rec.verifier.ivB64, rec.verifier.ctB64); if (bufToStr(pt) !== 'OK') throw new Error('Verifier mismatch'); } catch { throw new Error('Mot de passe incorrect.'); } const rawMaster = await aesGcmDecrypt(passKey, rec.encMasterKey.ivB64, rec.encMasterKey.ctB64); const masterKey = await crypto.subtle.importKey('raw', rawMaster, { name: 'AES-GCM' }, true, ['encrypt','decrypt']); AUTH_STATE.role = 'premium'; AUTH_STATE.passKey = passKey; AUTH_STATE.masterKey = masterKey; AUTH_STATE.unlockedAt = Date.now(); authUpdateHeaderUI(); } function authLogout() { AUTH_STATE.role = 'guest'; AUTH_STATE.passKey = null; AUTH_STATE.masterKey = null; AUTH_STATE.unlockedAt = 0; authUpdateHeaderUI(); } function isPremium() { return AUTH_STATE.role === 'premium' && !!AUTH_STATE.masterKey; } // ---------- Invité: verrouiller “Personnaliser le bouton” + séparateur “— — —” ---------- function authApplyAccessRules() { // ✅ ton menu réel =