'use strict'; // E2EE module — Signal-style hybrid encryption for group chat. // Uses Web Crypto API only. Private keys never leave this device. const E2EE = (() => { const GROUP_ID = 'info1'; const IDB_NAME = 'ifb_e2ee_v1'; const IDB_STORE = 'k'; const sub = crypto.subtle; const te = new TextEncoder(); const td = new TextDecoder(); const EC = { name: 'ECDH', namedCurve: 'P-256' }; // ── IndexedDB ───────────────────────────────────────────────── function openIDB() { return new Promise((res, rej) => { const r = indexedDB.open(IDB_NAME, 1); r.onupgradeneeded = e => e.target.result.createObjectStore(IDB_STORE); r.onsuccess = e => res(e.target.result); r.onerror = () => rej(r.error); }); } async function idbGet(key) { const db = await openIDB(); return new Promise((res, rej) => { const t = db.transaction(IDB_STORE, 'readonly').objectStore(IDB_STORE).get(key); t.onsuccess = () => res(t.result); t.onerror = () => rej(t.error); }); } async function idbSet(key, val) { const db = await openIDB(); return new Promise((res, rej) => { const t = db.transaction(IDB_STORE, 'readwrite').objectStore(IDB_STORE).put(val, key); t.onsuccess = () => res(); t.onerror = () => rej(t.error); }); } // ── REST helper ─────────────────────────────────────────────── async function rpc(method, path, body) { try { const r = await fetch('/api/' + path, { method, headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); return r.json(); } catch (e) { console.error('[E2EE] rpc', method, path, e); return null; } } // ── Binary helpers ──────────────────────────────────────────── function toB64(buf) { return btoa(String.fromCharCode(...new Uint8Array(buf))); } function fromB64(str) { return Uint8Array.from(atob(str), c => c.charCodeAt(0)); } function randomId() { try { return crypto.randomUUID(); } catch { return toB64(crypto.getRandomValues(new Uint8Array(18))); } } // ── Key generation & import ─────────────────────────────────── const genECDH = () => sub.generateKey(EC, true, ['deriveKey', 'deriveBits']); const importECPub = jwk => sub.importKey('jwk', jwk, EC, true, []); const importECPriv = jwk => sub.importKey('jwk', jwk, EC, true, ['deriveKey', 'deriveBits']); const importAES = raw => sub.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); const genAES = () => sub.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); const exportRaw = key => sub.exportKey('raw', key); // ECDH(myPriv, theirPub) → HKDF → AES-256-GCM wrap key async function deriveWrapKey(myPriv, theirPub) { const bits = await sub.deriveBits({ name: 'ECDH', public: theirPub }, myPriv, 256); const hk = await sub.importKey('raw', bits, 'HKDF', false, ['deriveKey']); return sub.deriveKey( { name: 'HKDF', hash: 'SHA-256', salt: te.encode('ifb-e2ee-v1'), info: te.encode(GROUP_ID) }, hk, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] ); } // ── AES-GCM encrypt/decrypt ─────────────────────────────────── async function aesEncrypt(key, data) { const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = await sub.encrypt({ name: 'AES-GCM', iv }, key, data); return { iv: toB64(iv), ct: toB64(ct) }; } async function aesDecrypt(key, ivB64, ctB64) { return sub.decrypt({ name: 'AES-GCM', iv: fromB64(ivB64) }, key, fromB64(ctB64)); } // ── Sender key wrap/unwrap ──────────────────────────────────── async function wrapSenderKey(rawKey, myPriv, theirPub) { const wk = await deriveWrapKey(myPriv, theirPub); const { iv, ct } = await aesEncrypt(wk, rawKey); return JSON.stringify({ iv, ct }); } async function unwrapSenderKey(encJson, myPriv, distPub) { const { iv, ct } = JSON.parse(encJson); const wk = await deriveWrapKey(myPriv, distPub); return aesDecrypt(wk, iv, ct); } // ── State ───────────────────────────────────────────────────── let myKeyPair = null; // { priv: CryptoKey, pub: CryptoKey } let myUserId = null; let activeSK = null; // { key: CryptoKey, kid: string } let kidCache = {}; // kid → CryptoKey // ── Core logic ──────────────────────────────────────────────── async function init(userId) { myUserId = userId; try { // Load or generate P-256 identity key pair in IndexedDB let privJwk = await idbGet('priv_' + userId); let pubJwk = await idbGet('pub_' + userId); if (!privJwk || !pubJwk) { const kp = await genECDH(); privJwk = await sub.exportKey('jwk', kp.privateKey); pubJwk = await sub.exportKey('jwk', kp.publicKey); await idbSet('priv_' + userId, privJwk); await idbSet('pub_' + userId, pubJwk); } myKeyPair = { priv: await importECPriv(privJwk), pub: await importECPub(pubJwk) }; // Publish public key (server upserts, idempotent) const pubResp = await rpc('POST', 'e2ee/public-key', { public_key_jwk: JSON.stringify(pubJwk) }); if (!pubResp || pubResp.error) console.warn('[E2EE] public-key publish failed', pubResp); // Fresh sender key per session → forward secrecy await rotateSenderKey(); } catch (e) { console.error('[E2EE] init failed', e); throw e; } } async function rotateSenderKey() { const kid = randomId(); const senderKey = await genAES(); const raw = await exportRaw(senderKey); // Distribute encrypted copy to every member who has a public key const usersResp = await rpc('GET', 'e2ee/users'); const users = Array.isArray(usersResp) ? usersResp : []; const dists = []; for (const u of users) { try { const theirPub = await importECPub(JSON.parse(u.public_key_jwk)); dists.push({ user_id: u.id, encrypted_key: await wrapSenderKey(raw, myKeyPair.priv, theirPub) }); } catch {} } // Always include self (may not be in users list yet) if (!dists.some(d => d.user_id === myUserId)) { dists.push({ user_id: myUserId, encrypted_key: await wrapSenderKey(raw, myKeyPair.priv, myKeyPair.pub) }); } await rpc('POST', 'e2ee/group-keys', { group_id: GROUP_ID, kid, keys: dists }); activeSK = { key: senderKey, kid }; kidCache[kid] = senderKey; } async function resolveKid(kid) { if (kidCache[kid]) return kidCache[kid]; const row = await rpc('GET', `e2ee/group-key?group_id=${GROUP_ID}&kid=${encodeURIComponent(kid)}`); if (!row || row.error) return null; try { const distPubResp = await rpc('GET', 'e2ee/public-key/' + row.distributor_user_id); if (!distPubResp?.public_key_jwk) return null; const distPub = await importECPub(JSON.parse(distPubResp.public_key_jwk)); const rawBuf = await unwrapSenderKey(row.encrypted_key, myKeyPair.priv, distPub); const key = await importAES(rawBuf); kidCache[kid] = key; return key; } catch { return null; } } async function encrypt(plaintext) { if (!activeSK) throw new Error('E2EE not initialized'); const { iv, ct } = await aesEncrypt(activeSK.key, te.encode(plaintext)); // ts included for replay detection on client side return JSON.stringify({ v: 1, kid: activeSK.kid, iv, ct, ts: Date.now() }); } async function decrypt(content) { let p; try { p = JSON.parse(content); } catch { return content; } // not encrypted if (!p.v || !p.kid || !p.iv || !p.ct) return content; // legacy plaintext const key = await resolveKid(p.kid); if (!key) return '[Nachricht nicht entschlusselbar]'; try { const buf = await aesDecrypt(key, p.iv, p.ct); return td.decode(buf); } catch { return '[Entschlusselung fehlgeschlagen]'; } } return { init, encrypt, decrypt, rotateSenderKey }; })();