From 17917e2997eb0bcb5b8430a55864ea5fd6fb1edb Mon Sep 17 00:00:00 2001 From: Dicken Date: Thu, 28 May 2026 00:22:44 +0200 Subject: [PATCH] Dateien nach "frontend/src" hochladen --- frontend/src/App.jsx | 219 ++++++++++++++++++++++++++++++++++++++++- frontend/src/crypto.js | 216 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 431 insertions(+), 4 deletions(-) create mode 100644 frontend/src/crypto.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c1c306d..d01a146 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,8 +5,26 @@ import CalendarWidget, { CalendarSettings } from './calendar.jsx'; import { DateiSettings } from './tools/dateien.jsx'; import { useConfirm } from './confirm.jsx'; import { HomeIcon, AppsIcon, AdminIcon, MoreIcon, UserIcon, LogOutIcon, ChevronIcon, UpdateIcon, TrashIcon, MessageIcon } from './icons.jsx'; +import { getOrCreateKeyPair, getPublicKeyJwk, getFingerprint, exportEncrypted, importEncrypted, generateNewKeyPair } from './crypto.js'; -const ICONS = ['🔗','🌐','📊','📁','🛠','📧','🗓','💾','🖥','📡','🔒','📖','🎯','⚡','🏠','🔧','📱','🎮','🚀','💡']; +const CONST_ICONS = ['🔗','🌐','📊','📁','🛠','📧','🗓','💾','🖥','📡','🔒','📖','🎯','⚡','🏠','🔧','📱','🎮','🚀','💡']; + +// Schlüsselpaar beim Login generieren und public key registrieren (fire & forget) +async function initNachrichtenKeys() { + const LS_KEY = 'dd_msg_keypair'; + let pubJwk; + const stored = localStorage.getItem(LS_KEY); + if (stored) { + try { pubJwk = JSON.parse(stored).pub; } catch { localStorage.removeItem(LS_KEY); } + } + if (!pubJwk) { + const kp = await crypto.subtle.generateKey({ name:'ECDH', namedCurve:'P-256' }, true, ['deriveKey']); + pubJwk = await crypto.subtle.exportKey('jwk', kp.publicKey); + const priv = await crypto.subtle.exportKey('jwk', kp.privateKey); + localStorage.setItem(LS_KEY, JSON.stringify({ pub: pubJwk, priv })); + } + try { await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(pubJwk) } }); } catch {} +} // ── Responsive Hook ─────────────────────────────────────────────────────────── function useIsMobile() { @@ -93,7 +111,7 @@ function Login({ onLogin }) { const [err,setErr]=useState(''); const [busy,setBusy]=useState(false); const go = async () => { setErr(''); setBusy(true); - try { const d=await api('/auth/login',{body:{username:u,password:p}}); localStorage.setItem('sk_token',d.token); onLogin(d.user); } + try { const d=await api('/auth/login',{body:{username:u,password:p}}); localStorage.setItem('sk_token',d.token); initNachrichtenKeys(); onLogin(d.user); } catch(e) { setErr(e.message); } finally { setBusy(false); } }; return ( @@ -496,7 +514,7 @@ function QuickLinks({ toast, mobile }) {
ICON
- {ICONS.map(ic => ( + {CONST_ICONS.map(ic => ( + ) : ( +
+ setExpPw(e.target.value)}/> + setExpPw2(e.target.value)}/> +
+ + +
+
+ )} + + + {/* Import */} + +

+ Importiere eine zuvor exportierte Schlüsseldatei. Der aktuelle Schlüssel wird ersetzt. +

+ {!showImport ? ( + + ) : ( +
+
+ + setImpFile(e.target.files?.[0] || null)} + style={{ ...S.inp, fontSize:13, padding:'9px 10px', cursor:'pointer' }}/> +
+ setImpPw(e.target.value)}/> +
+ + +
+
+ )} +
+ + {/* Reset – Danger Zone */} + +

+ ⚠ Gefahrenzone: Erzeugt ein neues Schlüsselpaar und löscht alle deine Nachrichten vom Server. + Alte Nachrichten sind danach nicht mehr lesbar. Geräte mit dem alten Schlüssel werden ausgesperrt. +

+ +
+
+ ); +} + // Field außerhalb definiert → Tastatur bleibt beim Tippen erhalten const Field = ({ label, type='text', value, onChange, placeholder='', autoCapitalize='off' }) => (
@@ -1160,7 +1356,7 @@ function AdminPanel({ toast, mobile, user }) {

Einstellungen

- {[['profil','Profil'],['dashboard','Dashboard'], ...(user?.role==='admin'?[['benutzer','Benutzer'],['backup','Backup']]:[])] + {[['profil','Profil'],['sicherheit','Sicherheit'],['dashboard','Dashboard'], ...(user?.role==='admin'?[['benutzer','Benutzer'],['backup','Backup']]:[])] .map(([k,l]) => (
)} + {section === 'sicherheit' && ( + + )} + {section === 'benutzer' && user?.role==='admin' && (
)} @@ -1313,6 +1513,17 @@ export default function App() { return()=>clearInterval(t); },[user]); + // Schlüssel sofort nach Login initialisieren und Public Key registrieren + useEffect(()=>{ + if(!user)return; + getOrCreateKeyPair().then(async kp=>{ + try{ + const jwk=await getPublicKeyJwk(kp); + await api('/tools/nachrichten/keys',{body:{public_key:JSON.stringify(jwk)}}); + }catch{} + }).catch(()=>{}); + },[user]); + useEffect(()=>{ if(!user)return; const poll=()=>api('/tools/nachrichten/unread').then(d=>setUnreadMsgs(d.count||0)).catch(()=>{}); diff --git a/frontend/src/crypto.js b/frontend/src/crypto.js new file mode 100644 index 0000000..69ae806 --- /dev/null +++ b/frontend/src/crypto.js @@ -0,0 +1,216 @@ +/** + * DickenDock E2E-Verschlüsselung + * + * Algorithmen: + * X25519 → Schlüsselaustausch (WebCrypto; Chrome 113+/Firefox 130+/Safari 17.4+) + * AES-256-GCM → Nachrichtenverschlüsselung + * PBKDF2-SHA256 → Export-Passwortverschlüsselung (200k Iterationen) + * ↳ statt Argon2id: kein WebCrypto-Support ohne WASM-Lib → keine externen Abhängigkeiten + * + * Private Key: ausschließlich in IndexedDB, niemals an den Server übertragen. + */ + +const IDB_NAME = 'dd_e2e_v1'; +const IDB_STORE = 'keys'; +const KEY_ID = 'identity'; + +// ── IndexedDB helpers ───────────────────────────────────────────────────────── +function _openDB() { + 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(k) { + const db = await _openDB(); + return new Promise((res, rej) => { + const r = db.transaction(IDB_STORE, 'readonly').objectStore(IDB_STORE).get(k); + r.onsuccess = () => res(r.result ?? null); + r.onerror = () => rej(r.error); + }); +} + +async function _idbSet(k, v) { + const db = await _openDB(); + return new Promise((res, rej) => { + const tx = db.transaction(IDB_STORE, 'readwrite'); + tx.objectStore(IDB_STORE).put(v, k); + tx.oncomplete = () => res(); + tx.onerror = () => rej(tx.error); + }); +} + +async function _idbDel(k) { + const db = await _openDB(); + return new Promise((res, rej) => { + const tx = db.transaction(IDB_STORE, 'readwrite'); + tx.objectStore(IDB_STORE).delete(k); + tx.oncomplete = () => res(); + tx.onerror = () => rej(tx.error); + }); +} + +// ── Base64 helpers ──────────────────────────────────────────────────────────── +const b64 = u8 => btoa(String.fromCharCode(...u8)); +const ub64 = s => Uint8Array.from(atob(s), c => c.charCodeAt(0)); + +// ── Internal ────────────────────────────────────────────────────────────────── +async function _importJwkPair(pub, priv) { + const publicKey = await crypto.subtle.importKey('jwk', pub, { name:'X25519' }, true, []); + const privateKey = await crypto.subtle.importKey('jwk', priv, { name:'X25519' }, true, ['deriveKey']); + return { publicKey, privateKey }; +} + +async function _pbkdf2Key(password, salt) { + const raw = await crypto.subtle.importKey( + 'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey'] + ); + return crypto.subtle.deriveKey( + { name:'PBKDF2', salt, iterations:200_000, hash:'SHA-256' }, + raw, + { name:'AES-GCM', length:256 }, false, ['encrypt','decrypt'] + ); +} + +// ── Keypair Management ──────────────────────────────────────────────────────── + +/** + * Lädt Keypair aus IndexedDB. + * Migriert altes localStorage-Format (P-256) → löscht es, erzeugt neues X25519-Keypair. + * Erstellt neues Keypair falls keins vorhanden. + */ +export async function getOrCreateKeyPair() { + // Migration: altes P-256-Keypair aus localStorage entfernen + if (localStorage.getItem('dd_msg_keypair')) { + localStorage.removeItem('dd_msg_keypair'); + // Kein Import möglich (Algorithmuswechsel P-256→X25519), neues Keypair wird unten erzeugt + } + + const stored = await _idbGet(KEY_ID); + if (stored) return stored; + + // Neues X25519-Keypair erzeugen + const kp = await crypto.subtle.generateKey({ name:'X25519' }, true, ['deriveKey']); + await _idbSet(KEY_ID, kp); + return kp; +} + +/** Neues Keypair erzeugen und in IndexedDB speichern (überschreibt vorhandenes). */ +export async function generateNewKeyPair() { + const kp = await crypto.subtle.generateKey({ name:'X25519' }, true, ['deriveKey']); + await _idbSet(KEY_ID, kp); + return kp; +} + +/** Keypair aus IndexedDB löschen. */ +export async function clearStoredKeyPair() { + await _idbDel(KEY_ID); +} + +/** Public Key als JWK exportieren (für Server-Registrierung). */ +export async function getPublicKeyJwk(kp) { + return crypto.subtle.exportKey('jwk', kp.publicKey); +} + +// ── Fingerprint ─────────────────────────────────────────────────────────────── + +/** + * SHA-256-Hash des Public Key, angezeigt als Kurzformat: ABCD-EF12-3456-CDEF + * Kann manuell verglichen werden um Man-in-the-Middle zu erkennen. + */ +export async function getFingerprint(kp) { + const jwk = await getPublicKeyJwk(kp); + const enc = new TextEncoder().encode(JSON.stringify(jwk)); + const hash = await crypto.subtle.digest('SHA-256', enc); + const hex = [...new Uint8Array(hash)] + .map(b => b.toString(16).padStart(2, '0')).join('').toUpperCase(); + return `${hex.slice(0,4)}-${hex.slice(4,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}`; +} + +// ── Export / Import ─────────────────────────────────────────────────────────── + +/** + * Keypair verschlüsselt als JSON-String exportieren. + * Verschlüsselung: PBKDF2-SHA256 (200k) → AES-256-GCM + */ +export async function exportEncrypted(kp, password) { + const pub = await crypto.subtle.exportKey('jwk', kp.publicKey); + const priv = await crypto.subtle.exportKey('jwk', kp.privateKey); + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const enc = await crypto.subtle.encrypt( + { name:'AES-GCM', iv }, + await _pbkdf2Key(password, salt), + new TextEncoder().encode(JSON.stringify({ pub, priv })) + ); + return JSON.stringify({ + v: 1, + alg: 'X25519+PBKDF2-SHA256-200k+AES-256-GCM', + salt: b64(salt), + iv: b64(iv), + data: b64(new Uint8Array(enc)), + }); +} + +/** + * Verschlüsseltes Keypair importieren, entschlüsseln und in IndexedDB speichern. + * Wirft bei falschem Passwort oder korrupter Datei einen Fehler. + */ +export async function importEncrypted(jsonStr, password) { + const obj = JSON.parse(jsonStr); + if (obj.v !== 1) throw new Error('Unbekanntes Export-Format'); + let dec; + try { + dec = await crypto.subtle.decrypt( + { name:'AES-GCM', iv: ub64(obj.iv) }, + await _pbkdf2Key(password, ub64(obj.salt)), + ub64(obj.data) + ); + } catch { + throw new Error('Entschlüsselung fehlgeschlagen – falsches Passwort?'); + } + const { pub, priv } = JSON.parse(new TextDecoder().decode(dec)); + const kp = await _importJwkPair(pub, priv); + await _idbSet(KEY_ID, kp); + return kp; +} + +// ── Message Encryption ──────────────────────────────────────────────────────── + +/** + * Gemeinsamen AES-256-GCM-Schlüssel aus eigenem Private Key + Public Key des Partners ableiten. + * Beide Seiten erhalten denselben Schlüssel (X25519-Eigenschaft). + */ +export async function deriveSharedKey(myPrivateKey, theirPubJwk) { + const theirKey = await crypto.subtle.importKey('jwk', theirPubJwk, { name:'X25519' }, false, []); + return crypto.subtle.deriveKey( + { name:'X25519', public: theirKey }, + myPrivateKey, + { name:'AES-GCM', length:256 }, false, ['encrypt','decrypt'] + ); +} + +/** Nachricht mit AES-256-GCM verschlüsseln. */ +export async function encryptMsg(sharedKey, text) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const enc = await crypto.subtle.encrypt( + { name:'AES-GCM', iv }, sharedKey, new TextEncoder().encode(text) + ); + return { + encrypted_content: b64(new Uint8Array(enc)), + iv: b64(iv), + }; +} + +/** Nachricht entschlüsseln. Wirft bei falschem Schlüssel/korrupten Daten. */ +export async function decryptMsg(sharedKey, ecB64, ivB64) { + const dec = await crypto.subtle.decrypt( + { name:'AES-GCM', iv: ub64(ivB64) }, + sharedKey, + ub64(ecB64) + ); + return new TextDecoder().decode(dec); +}