feat: add TOTP 2FA with QR code and manual secret entry

This commit is contained in:
Simon
2026-04-17 22:56:39 +02:00
parent ae789318ba
commit 8f75bc6a10
7 changed files with 959 additions and 26 deletions
+197
View File
@@ -0,0 +1,197 @@
'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 };
})();
+148 -12
View File
@@ -8,6 +8,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<script src="/e2ee.js"></script>
<style>
:root {
--blue: #2563eb; --blue-d: #1d4ed8;
@@ -1421,6 +1422,52 @@ footer {
</div>
</div>
<div class="settings-section" id="2fa-section">
<div class="settings-label">Zwei-Faktor-Authentifizierung (2FA)</div>
<div id="2fa-status-row" style="font-size:13px;color:var(--text-muted);margin-bottom:8px">Wird geladen…</div>
<!-- Setup flow -->
<div id="2fa-setup-area" style="display:none">
<div style="margin-bottom:10px;font-size:13px;color:var(--text-2)">Scanne den QR-Code mit deiner Authenticator-App (z.B. Google Authenticator, Authy).</div>
<img id="2fa-qr" style="width:180px;height:180px;border-radius:8px;border:1px solid var(--border);display:block;margin-bottom:8px" alt="QR Code">
<details style="margin-bottom:10px;font-size:12px">
<summary style="cursor:pointer;color:var(--text-muted);user-select:none">Kein Kamera? Manuell eingeben</summary>
<div style="margin-top:6px;padding:8px;background:var(--n-100);border-radius:6px;border:1px solid var(--border)">
<div style="color:var(--text-muted);margin-bottom:4px">Geheimschlüssel (Base32):</div>
<code id="2fa-secret" style="font-size:13px;word-break:break-all;color:var(--text);letter-spacing:.05em"></code>
<div style="color:var(--text-subtle);margin-top:4px;font-size:11px">In App: Konto manuell hinzufügen → TOTP → diesen Schlüssel eingeben</div>
</div>
</details>
<div class="settings-fields">
<input type="text" id="2fa-confirm-code" placeholder="6-stelliger Code zur Bestätigung" maxlength="6" inputmode="numeric" autocomplete="one-time-code">
<div style="display:flex;gap:8px;align-items:center">
<button class="btn-save" style="align-self:flex-start" onclick="confirm2FA()">Bestätigen &amp; aktivieren</button>
<button class="btn-cancel" style="align-self:flex-start" onclick="cancel2FASetup()">Abbrechen</button>
</div>
</div>
</div>
<!-- Disable flow -->
<div id="2fa-disable-area" style="display:none">
<div class="settings-fields">
<input type="password" id="2fa-disable-pw" placeholder="Aktuelles Passwort">
<input type="text" id="2fa-disable-code" placeholder="6-stelliger 2FA-Code" maxlength="6" inputmode="numeric" autocomplete="one-time-code">
<div style="display:flex;gap:8px">
<button class="btn-danger" style="align-self:flex-start" onclick="disable2FA()">2FA deaktivieren</button>
<button class="btn-cancel" style="align-self:flex-start" onclick="cancel2FADisable()">Abbrechen</button>
</div>
</div>
</div>
<!-- Idle buttons -->
<div id="2fa-idle-area" style="display:none">
<button class="btn-save" style="align-self:flex-start" onclick="setup2FA()">2FA einrichten</button>
</div>
<div id="2fa-enabled-area" style="display:none">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-size:13px;color:var(--green);font-weight:600">✓ 2FA ist aktiv</span>
</div>
<button class="btn-danger" style="font-size:12px;padding:5px 12px" onclick="showDisable2FA()">2FA deaktivieren</button>
</div>
</div>
<div class="settings-section">
<div class="danger-zone">
<p>Account und alle gespeicherten Daten werden <strong>unwiderruflich gelöscht</strong>.</p>
@@ -1572,7 +1619,7 @@ function loginUI(username,id,role,subject){
</div>`;
loadAll();
initChat(username);
initChat(username, id);
}
function toggleDropdown(el){
@@ -1870,9 +1917,92 @@ async function saveModal(){
function openSettings(){
document.getElementById('settings-overlay').style.display='flex';
document.getElementById('user-dropdown')?.classList.remove('open');
load2FAStatus();
}
function closeSettings(){document.getElementById('settings-overlay').style.display='none';}
async function load2FAStatus(){
const statusRow=document.getElementById('2fa-status-row');
document.getElementById('2fa-idle-area').style.display='none';
document.getElementById('2fa-enabled-area').style.display='none';
document.getElementById('2fa-setup-area').style.display='none';
document.getElementById('2fa-disable-area').style.display='none';
try {
const r=await api('GET','2fa/status');
statusRow.textContent='';
if(r.enabled){
document.getElementById('2fa-enabled-area').style.display='';
} else {
document.getElementById('2fa-idle-area').style.display='';
}
} catch(e) {
statusRow.textContent='Fehler beim Laden.';
}
}
async function setup2FA(){
document.getElementById('2fa-idle-area').style.display='none';
document.getElementById('2fa-status-row').textContent='QR-Code wird generiert…';
try {
const r=await api('POST','2fa/setup');
document.getElementById('2fa-status-row').textContent='';
if(r.error){toast(r.error,'error');document.getElementById('2fa-idle-area').style.display='';return;}
document.getElementById('2fa-qr').src=r.qr;
document.getElementById('2fa-secret').textContent=r.secret;
document.getElementById('2fa-confirm-code').value='';
document.getElementById('2fa-setup-area').style.display='';
document.getElementById('2fa-confirm-code').focus();
} catch(e) {
document.getElementById('2fa-status-row').textContent='';
toast('Fehler beim Generieren des QR-Codes','error');
document.getElementById('2fa-idle-area').style.display='';
}
}
function cancel2FASetup(){
document.getElementById('2fa-setup-area').style.display='none';
document.getElementById('2fa-idle-area').style.display='';
}
async function confirm2FA(){
const code=document.getElementById('2fa-confirm-code').value.trim();
if(!code){toast('Code eingeben','error');return;}
const r=await api('POST','2fa/confirm',{token:code});
if(r.ok){
toast('2FA aktiviert ✓','success');
document.getElementById('2fa-setup-area').style.display='none';
document.getElementById('2fa-enabled-area').style.display='';
} else {
toast(r.error,'error');
}
}
function showDisable2FA(){
document.getElementById('2fa-enabled-area').style.display='none';
document.getElementById('2fa-disable-pw').value='';
document.getElementById('2fa-disable-code').value='';
document.getElementById('2fa-disable-area').style.display='';
}
function cancel2FADisable(){
document.getElementById('2fa-disable-area').style.display='none';
document.getElementById('2fa-enabled-area').style.display='';
}
async function disable2FA(){
const pw=document.getElementById('2fa-disable-pw').value;
const code=document.getElementById('2fa-disable-code').value.trim();
if(!pw||!code){toast('Passwort und Code erforderlich','error');return;}
const r=await api('POST','2fa/disable',{password:pw,token:code});
if(r.ok){
toast('2FA deaktiviert','success');
document.getElementById('2fa-disable-area').style.display='none';
document.getElementById('2fa-idle-area').style.display='';
} else {
toast(r.error,'error');
}
}
async function changePassword(){
const cp=document.getElementById('pw-current').value;
const np=document.getElementById('pw-new').value;
@@ -1900,18 +2030,19 @@ function chatFmtTime(ts) {
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
function renderChatMsg(m, append) {
async function renderChatMsg(m, append) {
const el = document.getElementById('chat-msgs');
const isOwn = m.username === chatMyUsername;
const div = document.createElement('div');
div.className = 'chat-msg';
div.dataset.id = m.id;
const displayContent = await E2EE.decrypt(m.content);
div.innerHTML = `<div class="chat-msg-meta">
<span class="chat-msg-user${isOwn ? ' own' : ''}">${esc(m.username)}</span>
<span class="chat-msg-time">${chatFmtTime(m.created_at)}</span>
<button class="chat-msg-del" onclick="delChatMsg(${m.id})" title="Löschen">✕</button>
</div>
<div class="chat-msg-body">${esc(m.content)}</div>`;
<div class="chat-msg-body">${esc(displayContent)}</div>`;
if (append) {
el.appendChild(div);
el.scrollTop = el.scrollHeight;
@@ -1924,17 +2055,17 @@ async function loadChat() {
const msgs = await api('GET', 'chat');
const el = document.getElementById('chat-msgs');
el.innerHTML = '';
msgs.forEach(m => renderChatMsg(m, true));
for (const m of msgs) await renderChatMsg(m, true);
if (msgs.length) chatLastId = msgs[msgs.length - 1].id;
}
async function pollChat() {
try {
const msgs = await api('GET', 'chat?after=' + chatLastId);
msgs.forEach(m => {
renderChatMsg(m, true);
for (const m of msgs) {
await renderChatMsg(m, true);
chatLastId = Math.max(chatLastId, m.id);
});
}
} catch {}
chatPollTimer = setTimeout(pollChat, 3000);
}
@@ -1944,9 +2075,12 @@ async function sendChatMsg() {
const content = inp.value.trim();
if (!content) return;
inp.value = '';
const r = await api('POST', 'chat', { content });
if (r.error) { toast(r.error, 'error'); return; }
renderChatMsg(r, true);
let ciphertext;
try { ciphertext = await E2EE.encrypt(content); }
catch { toast('Verschlüsselung fehlgeschlagen', 'error'); inp.value = content; return; }
const r = await api('POST', 'chat', { content: ciphertext });
if (r.error) { toast(r.error, 'error'); inp.value = content; return; }
await renderChatMsg(r, true);
chatLastId = Math.max(chatLastId, r.id);
}
@@ -1957,9 +2091,11 @@ async function delChatMsg(id) {
document.querySelector(`.chat-msg[data-id="${id}"]`)?.remove();
}
function initChat(username) {
async function initChat(username, userId) {
chatMyUsername = username;
loadChat().then(() => pollChat());
await E2EE.init(userId);
await loadChat();
pollChat();
document.getElementById('chat-input').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMsg(); }
});
+40 -10
View File
@@ -209,17 +209,27 @@ footer a:hover { color: #2563eb; }
</div>
<form class="form active" id="form-login" onsubmit="doLogin(event)">
<div class="field">
<label for="l-user">Benutzername</label>
<input type="text" id="l-user" autocomplete="username" placeholder="dein.name" required>
<span style="font-size:11px;color:#9ca3af;margin-top:2px">Der Teil deiner Schul-E-Mail vor dem @</span>
<div id="login-step-1">
<div class="field" style="margin-bottom:14px">
<label for="l-user">Benutzername</label>
<input type="text" id="l-user" autocomplete="username" placeholder="dein.name" required>
<span style="font-size:11px;color:#9ca3af;margin-top:2px">Der Teil deiner Schul-E-Mail vor dem @</span>
</div>
<div class="field">
<label for="l-pass">Passwort</label>
<input type="password" id="l-pass" autocomplete="current-password" placeholder="••••••" required>
</div>
</div>
<div class="field">
<label for="l-pass">Passwort</label>
<input type="password" id="l-pass" autocomplete="current-password" placeholder="••••••" required>
<div id="login-step-2" style="display:none">
<div class="field">
<label for="l-totp">2FA-Code</label>
<input type="text" id="l-totp" autocomplete="one-time-code" placeholder="6-stelliger Code" maxlength="6" inputmode="numeric">
<span style="font-size:11px;color:#9ca3af;margin-top:2px">Code aus deiner Authenticator-App eingeben</span>
</div>
<button type="button" style="font-size:12px;color:#6b7280;background:none;border:none;cursor:pointer;padding:0;margin-top:4px" onclick="backToStep1()">← Zurück</button>
</div>
<div class="notice notice-red" id="login-err"></div>
<button class="btn-submit" type="submit">Anmelden</button>
<button class="btn-submit" type="submit" id="login-btn">Anmelden</button>
</form>
<form class="form" id="form-reg" onsubmit="doRegister(event)">
@@ -296,12 +306,32 @@ function showErr(id, msg) {
}
function clearErr(id) { document.getElementById(id).classList.remove('show'); }
let totpPending = false;
function backToStep1() {
totpPending = false;
document.getElementById('login-step-1').style.display = '';
document.getElementById('login-step-2').style.display = 'none';
document.getElementById('login-btn').textContent = 'Anmelden';
clearErr('login-err');
}
async function doLogin(e) {
e.preventDefault(); clearErr('login-err');
const r = await fetch('/api/login', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ username: document.getElementById('l-user').value, password: document.getElementById('l-pass').value }) });
const body = { username: document.getElementById('l-user').value, password: document.getElementById('l-pass').value };
if (totpPending) body.totp_token = document.getElementById('l-totp').value;
const r = await fetch('/api/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
const d = await r.json();
if (!r.ok) { showErr('login-err', d.error); return; }
if (d.requireTotp) {
totpPending = true;
document.getElementById('login-step-1').style.display = 'none';
document.getElementById('login-step-2').style.display = '';
document.getElementById('login-btn').textContent = 'Bestätigen';
document.getElementById('l-totp').value = '';
document.getElementById('l-totp').focus();
return;
}
window.location.href = '/';
}