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 ( -