217 lines
8.2 KiB
JavaScript
217 lines
8.2 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|