Dateien nach "frontend/src" hochladen

This commit is contained in:
2026-05-28 00:22:44 +02:00
parent 71c9061e33
commit 17917e2997
2 changed files with 431 additions and 4 deletions

View File

@@ -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 }) {
<div style={{ marginBottom:10 }}>
<div style={{ ...S.head, marginBottom:6 }}>ICON</div>
<div style={{ display:'flex', flexWrap:'wrap', gap:5 }}>
{ICONS.map(ic => (
{CONST_ICONS.map(ic => (
<button key={ic} onClick={() => setF(f => ({...f, icon:ic}))} style={{
width:36, height:36, borderRadius:7, fontSize:18, cursor:'pointer',
border:`1px solid ${form.icon===ic ? '#4ecdc4' : 'rgba(255,255,255,0.1)'}`,
@@ -1095,6 +1113,184 @@ function UserManagement({ toast }) {
);
}
// ── Sicherheit / Key-Management ───────────────────────────────────────────────
function SecuritySettings({ toast }) {
const [kp, setKp] = useState(null);
const [fingerprint, setFp] = useState('');
const [expPw, setExpPw] = useState('');
const [expPw2, setExpPw2] = useState('');
const [showExport, setShowExport] = useState(false);
const [impFile, setImpFile] = useState(null);
const [impPw, setImpPw] = useState('');
const [showImport, setShowImport] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
getOrCreateKeyPair()
.then(async k => {
setKp(k);
setFp(await getFingerprint(k));
})
.catch(() => {});
}, []);
const doExport = async () => {
if (!kp) return;
if (expPw.length < 8) { toast('Passwort mind. 8 Zeichen', 'error'); return; }
if (expPw !== expPw2) { toast('Passwörter stimmen nicht überein', 'error'); return; }
setBusy(true);
try {
const json = await exportEncrypted(kp, expPw);
const a = Object.assign(document.createElement('a'), {
href: URL.createObjectURL(new Blob([json], { type:'application/json' })),
download: 'dickendock-key.json',
});
a.click(); URL.revokeObjectURL(a.href);
setExpPw(''); setExpPw2(''); setShowExport(false);
toast('Schlüssel exportiert ✓');
} catch { toast('Export fehlgeschlagen', 'error'); }
setBusy(false);
};
const doImport = async () => {
if (!impFile || !impPw) { toast('Datei und Passwort erforderlich', 'error'); return; }
setBusy(true);
try {
const text = await impFile.text();
const newKp = await importEncrypted(text, impPw);
const jwk = await getPublicKeyJwk(newKp);
await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(jwk) } });
setKp(newKp);
setFp(await getFingerprint(newKp));
setImpFile(null); setImpPw(''); setShowImport(false);
toast('Schlüssel importiert ✓');
} catch(e) { toast(e.message, 'error'); }
setBusy(false);
};
const doReset = async () => {
if (!window.confirm(
'Neues Schlüsselpaar erzeugen?\n\n' +
'Alle bisherigen Nachrichten werden vom Server gelöscht und können nicht mehr gelesen werden. ' +
'Geräte mit dem alten Schlüssel können keine neuen Nachrichten mehr lesen.'
)) return;
setBusy(true);
try {
const newKp = await generateNewKeyPair();
const jwk = await getPublicKeyJwk(newKp);
await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(jwk) } });
await api('/tools/nachrichten/my-messages', { method:'DELETE' });
toast('Neues Schlüsselpaar aktiv App wird neu geladen…');
setTimeout(() => window.location.reload(), 1500);
} catch(e) { toast(e.message, 'error'); setBusy(false); }
};
const FpChar = ({ c }) => (
<span style={{ background:'rgba(255,255,255,0.06)', border:'1px solid rgba(255,255,255,0.09)',
borderRadius:5, padding:'5px 8px', fontFamily:'monospace', fontSize:14, letterSpacing:2,
color: c === '-' ? 'rgba(255,255,255,0.2)' : '#4ecdc4' }}>{c}</span>
);
return (
<div>
{/* Fingerprint */}
<Sec title="SCHLÜSSEL-FINGERPRINT">
<p style={{ color:'rgba(255,255,255,0.42)', fontSize:11, fontFamily:'monospace',
marginBottom:12, lineHeight:1.7 }}>
Vergleiche diesen Code mit deinem Gesprächspartner um sicherzustellen, dass kein Man-in-the-Middle vorliegt.
</p>
{fingerprint ? (
<div style={{ display:'flex', gap:5, flexWrap:'wrap', marginBottom:8 }}>
{fingerprint.split('').map((c, i) => <FpChar key={i} c={c}/>)}
</div>
) : (
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:11 }}>Wird geladen</div>
)}
<div style={{ color:'rgba(255,255,255,0.25)', fontSize:9, fontFamily:'monospace', marginTop:6 }}>
SHA-256(PublicKey) · X25519
</div>
</Sec>
{/* Export */}
<Sec title="SCHLÜSSEL EXPORTIEREN">
<p style={{ color:'rgba(255,255,255,0.42)', fontSize:11, fontFamily:'monospace',
marginBottom:12, lineHeight:1.7 }}>
Exportiere deinen privaten Schlüssel verschlüsselt als Datei um ihn auf einem anderen Gerät zu nutzen.
</p>
{!showExport ? (
<button onClick={() => setShowExport(true)} disabled={!kp}
style={{ ...S.btn('#4ecdc4'), width:'100%', textAlign:'center', padding:'10px 0' }}>
Schlüssel exportieren
</button>
) : (
<div>
<Field label="EXPORT-PASSWORT (mind. 8 Zeichen)" type="password"
value={expPw} onChange={e => setExpPw(e.target.value)}/>
<Field label="PASSWORT BESTÄTIGEN" type="password"
value={expPw2} onChange={e => setExpPw2(e.target.value)}/>
<div style={{ display:'flex', gap:8 }}>
<button onClick={doExport} disabled={busy}
style={{ ...S.btn('#4ecdc4'), flex:1, textAlign:'center', padding:'10px 0', opacity:busy?0.5:1 }}>
{busy ? '…' : '↓ Herunterladen'}
</button>
<button onClick={() => { setShowExport(false); setExpPw(''); setExpPw2(''); }}
style={{ ...S.btn('#ff6b9d', true), padding:'0 14px' }}>Abbrechen</button>
</div>
</div>
)}
</Sec>
{/* Import */}
<Sec title="SCHLÜSSEL IMPORTIEREN">
<p style={{ color:'rgba(255,255,255,0.42)', fontSize:11, fontFamily:'monospace',
marginBottom:12, lineHeight:1.7 }}>
Importiere eine zuvor exportierte Schlüsseldatei. Der aktuelle Schlüssel wird ersetzt.
</p>
{!showImport ? (
<button onClick={() => setShowImport(true)}
style={{ ...S.btn('#ffe66d'), width:'100%', textAlign:'center', padding:'10px 0' }}>
Schlüssel importieren
</button>
) : (
<div>
<div style={{ marginBottom:12 }}>
<label style={{ ...S.head, display:'block', marginBottom:4 }}>SCHLÜSSELDATEI (.json)</label>
<input type="file" accept=".json"
onChange={e => setImpFile(e.target.files?.[0] || null)}
style={{ ...S.inp, fontSize:13, padding:'9px 10px', cursor:'pointer' }}/>
</div>
<Field label="EXPORT-PASSWORT" type="password"
value={impPw} onChange={e => setImpPw(e.target.value)}/>
<div style={{ display:'flex', gap:8 }}>
<button onClick={doImport} disabled={busy || !impFile || !impPw}
style={{ ...S.btn('#ffe66d'), flex:1, textAlign:'center', padding:'10px 0',
opacity: busy || !impFile || !impPw ? 0.4 : 1 }}>
{busy ? '…' : '✓ Importieren'}
</button>
<button onClick={() => { setShowImport(false); setImpFile(null); setImpPw(''); }}
style={{ ...S.btn('#ff6b9d', true), padding:'0 14px' }}>Abbrechen</button>
</div>
</div>
)}
</Sec>
{/* Reset Danger Zone */}
<Sec title="SCHLÜSSEL ZURÜCKSETZEN">
<p style={{ color:'rgba(255,107,157,0.6)', fontSize:11, fontFamily:'monospace',
marginBottom:12, lineHeight:1.7 }}>
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.
</p>
<button onClick={doReset} disabled={busy}
style={{ ...S.btn('#ff6b9d'), width:'100%', textAlign:'center', padding:'10px 0',
opacity: busy ? 0.5 : 1 }}>
{busy ? '…' : '✕ Neues Schlüsselpaar erzeugen'}
</button>
</Sec>
</div>
);
}
// Field außerhalb definiert → Tastatur bleibt beim Tippen erhalten
const Field = ({ label, type='text', value, onChange, placeholder='', autoCapitalize='off' }) => (
<div style={{ marginBottom:12 }}>
@@ -1160,7 +1356,7 @@ function AdminPanel({ toast, mobile, user }) {
<h1 style={{ color:'#fff', fontFamily:"'Space Mono',monospace", fontSize:mobile?17:22, marginBottom:16 }}>Einstellungen</h1>
<div style={{ display:'flex', gap:6, marginBottom:18, flexWrap:'wrap' }}>
{[['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]) => (
<button key={k} onClick={()=>setSection(k)} style={{
padding:'6px 16px', borderRadius:20, fontFamily:'monospace', fontSize:12, cursor:'pointer',
@@ -1234,6 +1430,10 @@ function AdminPanel({ toast, mobile, user }) {
</div>
)}
{section === 'sicherheit' && (
<SecuritySettings toast={toast}/>
)}
{section === 'benutzer' && user?.role==='admin' && (
<div><UserManagement toast={toast}/></div>
)}
@@ -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(()=>{});

216
frontend/src/crypto.js Normal file
View File

@@ -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);
}