From 0659f55dc7204a7daada18989eba2068c6321e1a Mon Sep 17 00:00:00 2001 From: Dicken Date: Thu, 28 May 2026 00:23:06 +0200 Subject: [PATCH] Dateien nach "frontend/src/tools" hochladen --- frontend/src/tools/nachrichten.jsx | 287 +++++++++++++++-------------- 1 file changed, 145 insertions(+), 142 deletions(-) diff --git a/frontend/src/tools/nachrichten.jsx b/frontend/src/tools/nachrichten.jsx index 63545db..71e1575 100644 --- a/frontend/src/tools/nachrichten.jsx +++ b/frontend/src/tools/nachrichten.jsx @@ -1,133 +1,105 @@ import { useState, useEffect, useRef } from 'react'; import { api, S } from '../lib.js'; import { UserIcon, TrashIcon } from '../icons.jsx'; +import { + getOrCreateKeyPair, + getPublicKeyJwk, + deriveSharedKey, + encryptMsg, + decryptMsg, +} from '../crypto.js'; -// ── E2E Crypto ──────────────────────────────────────────────────────────────── -const LS_KEY = 'dd_msg_keypair'; - -async function getOrCreateKeyPair() { - const stored = localStorage.getItem(LS_KEY); - if (stored) { - try { - const { pub, priv } = JSON.parse(stored); - const publicKey = await crypto.subtle.importKey('jwk', pub, { name:'ECDH', namedCurve:'P-256' }, true, []); - const privateKey = await crypto.subtle.importKey('jwk', priv, { name:'ECDH', namedCurve:'P-256' }, true, ['deriveKey']); - return { publicKey, privateKey, pubJwk: pub }; - } catch { localStorage.removeItem(LS_KEY); } - } - const kp = await crypto.subtle.generateKey({ name:'ECDH', namedCurve:'P-256' }, true, ['deriveKey']); - const pub = await crypto.subtle.exportKey('jwk', kp.publicKey); - const priv = await crypto.subtle.exportKey('jwk', kp.privateKey); - localStorage.setItem(LS_KEY, JSON.stringify({ pub, priv })); - return { publicKey: kp.publicKey, privateKey: kp.privateKey, pubJwk: pub }; -} - -async function deriveSharedKey(myPrivate, theirPublicJwk) { - const theirKey = await crypto.subtle.importKey( - 'jwk', theirPublicJwk, { name:'ECDH', namedCurve:'P-256' }, false, [] - ); - return crypto.subtle.deriveKey( - { name:'ECDH', public: theirKey }, - myPrivate, - { name:'AES-GCM', length:256 }, - false, ['encrypt','decrypt'] - ); -} - -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: btoa(String.fromCharCode(...new Uint8Array(enc))), - iv: btoa(String.fromCharCode(...iv)), - }; -} - -async function decryptMsg(sharedKey, encrypted_content, ivB64) { - const enc = Uint8Array.from(atob(encrypted_content), c => c.charCodeAt(0)); - const ivArr = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0)); - const dec = await crypto.subtle.decrypt({ name:'AES-GCM', iv: ivArr }, sharedKey, enc); - return new TextDecoder().decode(dec); -} - -// ── Main Component ──────────────────────────────────────────────────────────── export default function Nachrichten({ toast, mobile }) { - const [kp, setKp] = useState(null); - const [users, setUsers] = useState([]); - const [activeUser, setActiveUser] = useState(null); - const [messages, setMessages] = useState([]); - const [sharedKey, setSharedKey] = useState(null); - const [decrypted, setDecrypted] = useState({}); - const [text, setText] = useState(''); - const [sending, setSending] = useState(false); + const [kp, setKp] = useState(null); + const [initError, setInitError] = useState(false); + const [users, setUsers] = useState([]); + const [activeUser, setActiveUser] = useState(null); + const [messages, setMessages] = useState([]); + const [sharedKey, setSharedKey] = useState(null); // null | CryptoKey | 'error' + const [decrypted, setDecrypted] = useState({}); + const [text, setText] = useState(''); + const [sending, setSending] = useState(false); const bottomRef = useRef(null); const pollRef = useRef(null); - // Init key pair + register public key + // ── Initialisierung ────────────────────────────────────────────────────────── useEffect(() => { - getOrCreateKeyPair().then(async k => { - setKp(k); - try { await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(k.pubJwk) } }); } - catch {} - }); - loadUsers(); + getOrCreateKeyPair() + .then(async k => { + setKp(k); + try { + const jwk = await getPublicKeyJwk(k); + await api('/tools/nachrichten/keys', { body: { public_key: JSON.stringify(jwk) } }); + } catch {} + loadUsers(); + }) + .catch(() => setInitError(true)); }, []); - const loadUsers = async () => { - try { setUsers(await api('/tools/nachrichten/users')); } catch {} - }; - - // Poll messages in active conversation + // ── Polling ────────────────────────────────────────────────────────────────── useEffect(() => { clearInterval(pollRef.current); if (!activeUser) return; loadMessages(activeUser.id); - pollRef.current = setInterval(() => loadMessages(activeUser.id), 15000); + pollRef.current = setInterval(() => loadMessages(activeUser.id), 15_000); return () => clearInterval(pollRef.current); }, [activeUser?.id]); - // Derive shared key when conversation or own key changes + // ── Shared Key ─────────────────────────────────────────────────────────────── useEffect(() => { setSharedKey(null); setDecrypted({}); if (!kp || !activeUser?.public_key) return; try { - deriveSharedKey(kp.privateKey, JSON.parse(activeUser.public_key)) + const pubJwk = JSON.parse(activeUser.public_key); + deriveSharedKey(kp.privateKey, pubJwk) .then(setSharedKey) - .catch(() => {}); - } catch {} + .catch(() => setSharedKey('error')); + } catch { + setSharedKey('error'); + } }, [activeUser?.id, kp]); - // Decrypt messages whenever messages or sharedKey changes + // ── Entschlüsseln ──────────────────────────────────────────────────────────── useEffect(() => { - if (!sharedKey || !messages.length) { setDecrypted({}); return; } + if (sharedKey === null || sharedKey === 'error' || !messages.length) { + setDecrypted({}); + return; + } let cancelled = false; (async () => { const result = {}; for (const m of messages) { if (cancelled) return; - try { result[m.id] = await decryptMsg(sharedKey, m.encrypted_content, m.iv); } - catch { result[m.id] = '🔒 Nicht entschlüsselbar'; } + try { + result[m.id] = await decryptMsg(sharedKey, m.encrypted_content, m.iv); + } catch { + result[m.id] = null; // null = nicht entschlüsselbar + } } if (!cancelled) setDecrypted(result); })(); return () => { cancelled = true; }; }, [messages, sharedKey]); - // Scroll to bottom on new messages + // ── Scroll to bottom ───────────────────────────────────────────────────────── useEffect(() => { - bottomRef.current?.scrollIntoView({ behavior:'smooth' }); + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages.length]); - const loadMessages = async (userId) => { + // ── Handlers ───────────────────────────────────────────────────────────────── + const loadUsers = async () => { + try { setUsers(await api('/tools/nachrichten/users')); } catch {} + }; + + const loadMessages = async userId => { try { - const msgs = await api(`/tools/nachrichten/messages/${userId}`); - setMessages(msgs); + setMessages(await api(`/tools/nachrichten/messages/${userId}`)); await loadUsers(); } catch {} }; - const selectUser = (u) => { + const selectUser = u => { setActiveUser(u); setMessages([]); setDecrypted({}); @@ -135,47 +107,56 @@ export default function Nachrichten({ toast, mobile }) { const send = async () => { if (!text.trim() || !kp || !activeUser) return; - if (!activeUser.public_key) { toast('Empfänger hat noch kein Schlüsselpaar', 'error'); return; } - if (!sharedKey) { toast('Schlüssel wird noch vorbereitet…', 'error'); return; } + if (!activeUser.public_key) { toast('Empfänger hat noch keinen Schlüssel', 'error'); return; } + if (sharedKey === 'error') { toast('Schlüssel inkompatibel – Empfänger muss Schlüssel neu generieren', 'error'); return; } + if (!sharedKey) { toast('Schlüssel wird vorbereitet…', 'error'); return; } setSending(true); try { const { encrypted_content, iv } = await encryptMsg(sharedKey, text.trim()); await api(`/tools/nachrichten/messages/${activeUser.id}`, { body: { encrypted_content, iv } }); setText(''); await loadMessages(activeUser.id); - } catch (e) { toast(e.message, 'error'); } + } catch(e) { toast(e.message, 'error'); } setSending(false); }; - const deleteMsg = async (id) => { + const deleteMsg = async id => { try { await api(`/tools/nachrichten/messages/${id}`, { method:'DELETE' }); setMessages(p => p.filter(m => m.id !== id)); - } catch (e) { toast(e.message, 'error'); } + } catch(e) { toast(e.message, 'error'); } }; const deleteConversation = async () => { + if (!window.confirm(`Gesamten Verlauf mit ${activeUser.username} löschen?`)) return; try { await api(`/tools/nachrichten/conversation/${activeUser.id}`, { method:'DELETE' }); setMessages([]); await loadUsers(); - } catch (e) { toast(e.message, 'error'); } + } catch(e) { toast(e.message, 'error'); } }; + // ── Layout ─────────────────────────────────────────────────────────────────── const showList = !mobile || !activeUser; const showChat = !mobile || !!activeUser; - - const containerH = mobile + const h = mobile ? 'calc(100dvh - 56px - env(safe-area-inset-bottom, 0px))' : '100vh'; - return ( -
+ if (initError) return ( +
+ ⚠ Verschlüsselung konnte nicht initialisiert werden.
+ Dein Browser unterstützt X25519 möglicherweise nicht (erfordert Chrome 113+/Firefox 130+/Safari 17.4+). +
+ ); - {/* ── Conversation List ────────────────────────────────────────── */} + return ( +
+ + {/* ── Konversationsliste ─────────────────────────────────────────── */} {showList && (
@@ -184,41 +165,42 @@ export default function Nachrichten({ toast, mobile }) {
{users.length === 0 ? ( -
- Keine anderen Benutzer +
+ Keine anderen Benutzer vorhanden
) : users.map(u => ( ))} @@ -226,14 +208,14 @@ export default function Nachrichten({ toast, mobile }) {
)} - {/* ── Chat Panel ───────────────────────────────────────────────── */} + {/* ── Chat ──────────────────────────────────────────────────────── */} {showChat && (
{!activeUser ? (
-
-
+
+
Benutzer auswählen
@@ -249,69 +231,80 @@ export default function Nachrichten({ toast, mobile }) { )}
- +
-
-
{activeUser.username}
-
- 🔒 E2E VERSCHLÜSSELT +
+
+ {activeUser.username} +
+
+ {sharedKey === 'error' ? '⚠ SCHLÜSSEL INKOMPATIBEL' : '🔒 E2E VERSCHLÜSSELT · X25519 + AES-256-GCM'}
-
- {/* Messages */} + {/* Nachrichten */}
{!activeUser.public_key && ( -
- ⚠ Dieser Benutzer hat das Nachrichten-Tool noch nicht geöffnet und besitzt noch kein Schlüsselpaar. -
+ + ⚠ Dieser Benutzer hat die Nachrichten noch nicht geöffnet und besitzt noch kein Schlüsselpaar. + )} - {messages.length === 0 && activeUser.public_key && ( + {sharedKey === 'error' && ( + + ⚠ Schlüssel inkompatibel – der Empfänger verwendet ein anderes Schlüsselformat (z. B. altes P-256). + Er muss in Einstellungen → Sicherheit einen neuen Schlüssel generieren. + + )} + {messages.length === 0 && activeUser.public_key && sharedKey !== 'error' && (
- Noch keine Nachrichten – schreib als Erste(r) + Noch keine Nachrichten
)} {messages.map(m => { const isMine = !!m.is_mine; const txt = decrypted[m.id]; return ( -
+
{!isMine && (
- +
)}
{txt === undefined ? ( - + + ) : txt === null ? ( + + 🔒 Nicht entschlüsselbar + ) : ( {txt} )}
- - {new Date(m.created_at).toLocaleTimeString('de-DE',{ hour:'2-digit', minute:'2-digit' })} + + {new Date(m.created_at).toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' })}