Files
dickendock/frontend/src/crypto.js

217 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}