Files
ifb-schulapp/public/e2ee.js
T

198 lines
8.5 KiB
JavaScript

'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 };
})();