198 lines
8.5 KiB
JavaScript
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 };
|
|
})();
|