Dateien nach "frontend/src" hochladen
This commit is contained in:
@@ -5,8 +5,26 @@ import CalendarWidget, { CalendarSettings } from './calendar.jsx';
|
|||||||
import { DateiSettings } from './tools/dateien.jsx';
|
import { DateiSettings } from './tools/dateien.jsx';
|
||||||
import { useConfirm } from './confirm.jsx';
|
import { useConfirm } from './confirm.jsx';
|
||||||
import { HomeIcon, AppsIcon, AdminIcon, MoreIcon, UserIcon, LogOutIcon, ChevronIcon, UpdateIcon, TrashIcon, MessageIcon } from './icons.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 ───────────────────────────────────────────────────────────
|
// ── Responsive Hook ───────────────────────────────────────────────────────────
|
||||||
function useIsMobile() {
|
function useIsMobile() {
|
||||||
@@ -93,7 +111,7 @@ function Login({ onLogin }) {
|
|||||||
const [err,setErr]=useState(''); const [busy,setBusy]=useState(false);
|
const [err,setErr]=useState(''); const [busy,setBusy]=useState(false);
|
||||||
const go = async () => {
|
const go = async () => {
|
||||||
setErr(''); setBusy(true);
|
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); }
|
catch(e) { setErr(e.message); } finally { setBusy(false); }
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
@@ -496,7 +514,7 @@ function QuickLinks({ toast, mobile }) {
|
|||||||
<div style={{ marginBottom:10 }}>
|
<div style={{ marginBottom:10 }}>
|
||||||
<div style={{ ...S.head, marginBottom:6 }}>ICON</div>
|
<div style={{ ...S.head, marginBottom:6 }}>ICON</div>
|
||||||
<div style={{ display:'flex', flexWrap:'wrap', gap:5 }}>
|
<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={{
|
<button key={ic} onClick={() => setF(f => ({...f, icon:ic}))} style={{
|
||||||
width:36, height:36, borderRadius:7, fontSize:18, cursor:'pointer',
|
width:36, height:36, borderRadius:7, fontSize:18, cursor:'pointer',
|
||||||
border:`1px solid ${form.icon===ic ? '#4ecdc4' : 'rgba(255,255,255,0.1)'}`,
|
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
|
// Field außerhalb definiert → Tastatur bleibt beim Tippen erhalten
|
||||||
const Field = ({ label, type='text', value, onChange, placeholder='', autoCapitalize='off' }) => (
|
const Field = ({ label, type='text', value, onChange, placeholder='', autoCapitalize='off' }) => (
|
||||||
<div style={{ marginBottom:12 }}>
|
<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>
|
<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' }}>
|
<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]) => (
|
.map(([k,l]) => (
|
||||||
<button key={k} onClick={()=>setSection(k)} style={{
|
<button key={k} onClick={()=>setSection(k)} style={{
|
||||||
padding:'6px 16px', borderRadius:20, fontFamily:'monospace', fontSize:12, cursor:'pointer',
|
padding:'6px 16px', borderRadius:20, fontFamily:'monospace', fontSize:12, cursor:'pointer',
|
||||||
@@ -1234,6 +1430,10 @@ function AdminPanel({ toast, mobile, user }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{section === 'sicherheit' && (
|
||||||
|
<SecuritySettings toast={toast}/>
|
||||||
|
)}
|
||||||
|
|
||||||
{section === 'benutzer' && user?.role==='admin' && (
|
{section === 'benutzer' && user?.role==='admin' && (
|
||||||
<div><UserManagement toast={toast}/></div>
|
<div><UserManagement toast={toast}/></div>
|
||||||
)}
|
)}
|
||||||
@@ -1313,6 +1513,17 @@ export default function App() {
|
|||||||
return()=>clearInterval(t);
|
return()=>clearInterval(t);
|
||||||
},[user]);
|
},[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(()=>{
|
useEffect(()=>{
|
||||||
if(!user)return;
|
if(!user)return;
|
||||||
const poll=()=>api('/tools/nachrichten/unread').then(d=>setUnreadMsgs(d.count||0)).catch(()=>{});
|
const poll=()=>api('/tools/nachrichten/unread').then(d=>setUnreadMsgs(d.count||0)).catch(()=>{});
|
||||||
|
|||||||
216
frontend/src/crypto.js
Normal file
216
frontend/src/crypto.js
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user