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