Phase 45: Zero-Knowledge E2EE-Architektur
Arc OS — "Wir können deine Daten nicht lesen, selbst wenn wir es wollten" Autor: Sentinel (Security Architect) | Datum: 2026-04-24 Status: FERTIG ✅ (2026-04-28) — Issues #16–#20 abgeschlossen Genehmigt von: CEO
Implementierungs-Checkliste
- ✅ 45.1 — WebCrypto-Wrapper (
frontend/src/crm/crypto/e2ee.ts, 214 Zeilen) - ✅ 45.2 — Vault-Feld-Verschlüsselung (
shared/vault.ts: encryptField/decryptField/isFieldEncrypted) - ✅ 45.3 — Chat-Nachrichten At-Rest-Verschlüsselung (Migration 015, db.ts Auto-Encrypt/Decrypt)
- ✅ 45.4 — Recovery-Keys (Migration 016, 4 API-Endpunkte, RecoveryKeySection-UI)
- ✅ 45.5 — CSP/Sicherheits-Header + PII-Bereiniger (
shared/pii-sanitizer.ts) - 🔲 45.6 — Erweitert (Multi-Device-Sync, Forward Secrecy, Datenspeicherung) — P2 verschoben
Das Problem
Aktueller Zustand (Phase 43): Server speichert Benutzerdaten im Klartext.
-- data/citadel.db (SQLite, unverschlüsselte Datei)
CREATE TABLE chat_messages (
content TEXT NOT NULL -- ❌ "sk-ant-abc123xyz (mein API-Key)"
);
CREATE TABLE account_settings (
anthropic_key TEXT, -- ❌ "sk-ant-live-prod-key-123"
openai_key TEXT -- ❌ "sk-proj-sensitive-456"
);
CREATE TABLE users (
totp_secret TEXT -- ❌ "JBSWY3DPEHPK3PXP" (2FA-Seed)
);
Angriffszenarien:
- Datenbank-Breach — Angreifer lädt
citadel.dbherunter → liest alle Nachrichten, API-Keys, TOTP-Seeds - Server-Kompromittierung — Angreifer erhält SSH-Zugang →
sqlite3 citadel.db .dump→ vollständiger Datenexport - Insider-Bedrohung — Admin mit Root-Zugang → liest Benutzerkonversationen
- Backup-Leck — unverschlüsseltes DB-Backup in die Cloud hochgeladen → exponiert
- Rechtlicher Zwang — Gerichtsbeschluss erzwingt Datenübergabe → alle Benutzerdaten lesbar
Aktuelles Urteil: Sicher für Multi-Tenancy (Benutzer können nicht auf gegenseitige Daten zugreifen), NICHT sicher für Benutzerprivatsphäre (Server kann alles lesen).
Die Vision: Zero-Knowledge-Architektur
Prinzip: Der Server ist nicht vertrauenswürdig. Selbst mit Root-SSH-Zugang + Datenbankzugang können wir Benutzerdaten nicht entschlüsseln.
Was sich ändert:
VORHER (Phase 43):
Benutzer tippt Nachricht → Server speichert Klartext → Admins können lesen ❌
NACHHER (Phase 45):
Benutzer tippt Nachricht → Browser verschlüsselt → Server speichert Blob → Admins KÖNNEN NICHT lesen ✅
Das Versprechen:
"Deine Daten sind mit einem Schlüssel verschlüsselt, der von deinem Passwort abgeleitet wird. Wir haben dein Passwort nicht, also können wir deine Daten nicht entschlüsseln — selbst wenn wir es wollten."
Vorbild: Signal-Protokoll (vereinfacht), ProtonMail, 1Password.
Entscheidungsanalyse: Verschlüsselungsmodelle
Option A: Serverseitige Verschlüsselung (nur At-Rest)
Benutzer → Server → mit Server-Schlüssel verschlüsseln → in DB speichern
| Kriterium | Bewertung | Details |
|---|---|---|
| Sicherheit gegen Außenstehende | Mittel | DB-Datei verschlüsselt, aber Server hat Schlüssel → Root-Zugang = kann entschlüsseln |
| Sicherheit gegen Admin | NULL | Admin hat Server-Schlüssel → voller Zugriff |
| Compliance (DSGVO) | Schwach | "Datenschutz" aber kein Zero-Knowledge |
| Komplexität | Gering | SQLCipher oder PRAGMA key |
| Schlüssel-Recovery | Einfach | Server hat Schlüssel → keine Benutzeraktion nötig |
Urteil: Schützt gegen gestohlene Festplatte, NICHT gegen Server-Kompromittierung oder Insider-Bedrohung.
Option B: Anwendungsschicht-Verschlüsselung (Serverseitig)
Benutzer → Server → mit Vault-Schlüssel verschlüsseln → in DB speichern
Server hat Vault-Schlüssel (AES-256-GCM in vault.json)
| Kriterium | Bewertung | Details |
|---|---|---|
| Sicherheit gegen Außenstehende | Mittel | Besser als Klartext, aber Vault-Schlüssel auf demselben Server |
| Sicherheit gegen Admin | NULL | Admin hat vault.json → alles entschlüsseln |
| Compliance | Schwach | Immer noch kein Zero-Knowledge |
| Komplexität | Mittel | Feld-Level-Verschlüsselungs-Wrapper |
| Schlüssel-Recovery | Einfach | Serverseitig verwaltet |
Urteil: Marginal besser als Option A. Besteht immer noch nicht den "Insider-Bedrohungs"-Test.
Option C: End-to-End-Verschlüsselung (Zero-Knowledge) — GEWÄHLT
Benutzer → Browser verschlüsselt mit Master-Key (NIEMALS an Server gesendet) → Server speichert Blob
Server kann nicht entschlüsseln (kein Schlüssel)
| Kriterium | Bewertung | Details |
|---|---|---|
| Sicherheit gegen Außenstehende | HOCH | Verschlüsselte Daten + kein Schlüssel = nutzlos |
| Sicherheit gegen Admin | HOCH | Admin hat Datenbank aber kann nicht entschlüsseln |
| Compliance (DSGVO) | AUSGEZEICHNET | Echte Datenminimierung (Art. 25) |
| Komplexität | HOCH | WebCrypto API, Schlüsselverwaltung, Recovery-UX |
| Schlüssel-Recovery | SCHWER | Benutzer verliert Passwort = Daten verloren (erfordert Recovery-Key) |
Urteil: Einzige Option, die Zero-Knowledge liefert. Komplexitätskosten lohnen sich für Benutzerprivatsphäre.
Architekturüberblick
1. Zwei-Schlüssel-Ableitung (Split-Brain-Design)
Problem: Passwort wird zur Auth an Server gesendet. Wie Verschlüsselungsschlüssel ableiten, ohne Passwort zu senden?
Lösung: ZWEI Schlüssel aus Passwort mit verschiedenen Salts ableiten.
Benutzerpasswort: "MySecurePass123!"
│
├─ PBKDF2(password, salt="citadel-auth-v1", 100k iter)
│ ↓
│ authBits (256-bit)
│ ↓
│ bcrypt(authBits, cost=12) → authHash
│ ↓
│ AN SERVER SENDEN (für Login-Verifizierung)
│
└─ PBKDF2(password, salt="citadel-master-v1", 100k iter)
↓
masterKey (AES-256-GCM Verschlüsselungsschlüssel)
↓
BLEIBT IM BROWSER (sessionStorage)
WIRD NIEMALS an Server gesendet
Sicherheitseigenschaft: Server kompromittiert → Angreifer erhält authHash → kann masterKey nicht ableiten (verschiedenes Salt = verschiedene Ausgabe).
Code (frontend/src/crypto/e2ee.ts):
// Auth-Hash (an Server gesendet)
async function deriveAuthHash(password: string): Promise<string> {
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits"]
);
const bits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt: new TextEncoder().encode("citadel-auth-v1"),
iterations: 100000,
hash: "SHA-256"
},
keyMaterial,
256
);
// Server wird dies nochmals mit bcrypt hashen
return Buffer.from(bits).toString("hex");
}
// Master-Key (im Browser gespeichert)
async function deriveMasterKey(password: string): Promise<CryptoKey> {
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveKey"]
);
const masterKey = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: new TextEncoder().encode("citadel-master-v1"),
iterations: 100000,
hash: "SHA-256"
},
keyMaterial,
{ name: "AES-GCM", length: 256 },
false, // NICHT extrahierbar (kann nicht durchsickern)
["encrypt", "decrypt"]
);
return masterKey;
}
2. Registrierungsfluss
┌─────────────────────────────────────────────────────────────┐
│ Benutzer-Registrierung (Browser) │
├─────────────────────────────────────────────────────────────┤
│ 1. Benutzer gibt ein: E-Mail, Passwort │
│ │
│ 2. Browser leitet ab: │
│ authHash = PBKDF2(password, "auth-salt") → bcrypt │
│ masterKey = PBKDF2(password, "master-salt") → AES-Key │
│ │
│ 3. POST /api/auth/register │
│ { │
│ email: "[email protected]", │
│ authHash: "2a12...bcrypt..." ← in DB gespeichert │
│ } │
│ │
│ 4. Server: │
│ - bcrypt(authHash) → password_hash (doppelter Hash) │
│ - INSERT INTO users (email, password_hash) │
│ - Verifizierungs-E-Mail senden │
│ │
│ 5. Browser: │
│ - masterKey in sessionStorage speichern │
│ - Recovery-Key generieren (masterKey verschlüsseln) │
│ - Recovery-Key dem Benutzer zeigen: ⚠️ SPEICHERN! │
│ "A83Z-KL9P-MM4X-VN2Q-8JC7" │
│ - Benutzer lädt PDF herunter │
│ │
│ 6. POST /api/crm/account/recovery-key │
│ { │
│ encrypted_master_key: base64(...), ← AES-GCM-Blob │
│ recovery_key_iv: base64(...) │
│ } │
└─────────────────────────────────────────────────────────────┘
3. Login-Fluss
┌─────────────────────────────────────────────────────────────┐
│ Benutzer-Login (Browser) │
├─────────────────────────────────────────────────────────────┤
│ 1. Benutzer gibt ein: E-Mail, Passwort │
│ │
│ 2. Browser leitet ab: │
│ authHash = PBKDF2(password, "auth-salt") → bcrypt │
│ masterKey = PBKDF2(password, "master-salt") → AES-Key │
│ │
│ 3. POST /api/auth/login │
│ { │
│ email: "[email protected]", │
│ authHash: "2a12..." │
│ } │
│ │
│ 4. Server: │
│ - user.password_hash aus DB abrufen │
│ - bcrypt.compare(authHash, password_hash) │
│ - Bei Übereinstimmung: JWT-Token generieren │
│ - Gibt { token, user_id } zurück │
│ │
│ 5. Browser: │
│ - JWT in localStorage speichern (für API-Auth) │
│ - masterKey in sessionStorage speichern (zum Entschlüsseln) │
│ - Bereit zum Verschlüsseln/Entschlüsseln von Daten │
└─────────────────────────────────────────────────────────────┘
4. Nachricht verschlüsseln & senden
┌─────────────────────────────────────────────────────────────┐
│ Chat-Nachricht senden (Browser) │
├─────────────────────────────────────────────────────────────┤
│ 1. Benutzer tippt: "Deploy to production with sk-ant-xyz123" │
│ │
│ 2. Browser verschlüsselt: │
│ plaintext = "Deploy to production with sk-ant-xyz123" │
│ iv = crypto.getRandomValues(12 bytes) ← zufällige Nonce │
│ ciphertext = AES-GCM.encrypt(plaintext, masterKey, iv) │
│ │
│ 3. POST /api/crm/projects/arc-v2/chat │
│ { │
│ worker_id: "developer", │
│ content_encrypted: "8a9f3c...", ← base64-Blob │
│ content_iv: "7b2e1a..." ← base64-IV │
│ // KEIN Klartext-content-Feld │
│ } │
│ │
│ 4. Server: │
│ - JWT verifizieren (Benutzer autorisiert) │
│ - INSERT INTO chat_messages ( │
│ project_name, │
│ worker_id, │
│ role = 'user', │
│ content_encrypted = BLOB, ← für Server undurchsichtig │
│ content_iv = BLOB, │
│ timestamp │
│ ) │
│ - Gibt { message_id } zurück │
│ │
│ 5. Server KANN "sk-ant-xyz123" nicht lesen — es ist verschlüsselt │
└─────────────────────────────────────────────────────────────┘
5. Nachricht empfangen & entschlüsseln
┌─────────────────────────────────────────────────────────────┐
│ Chat-Historie abrufen (Browser) │
├─────────────────────────────────────────────────────────────┤
│ 1. GET /api/crm/projects/arc-v2/chat/history │
│ Authorization: Bearer <JWT> │
│ │
│ 2. Server: │
│ - JWT + Eigentümerschaft verifizieren (canAccessProject) │
│ - SELECT content_encrypted, content_iv, timestamp │
│ FROM chat_messages │
│ WHERE project_name = 'arc-v2' │
│ ORDER BY timestamp DESC │
│ LIMIT 50 │
│ - Gibt [{ content_encrypted, content_iv, ... }] zurück │
│ │
│ 3. Browser entschlüsselt JEDE Nachricht: │
│ for (const msg of messages) { │
│ const ciphertext = base64Decode(msg.content_encrypted); │
│ const iv = base64Decode(msg.content_iv); │
│ const plaintext = AES-GCM.decrypt( │
│ ciphertext, │
│ masterKey, ← aus sessionStorage │
│ iv │
│ ); │
│ displayMessage(plaintext); │
│ } │
│ │
│ 4. Benutzer sieht: "Deploy to production with sk-ant-xyz123" │
│ Server sah: bedeutungslosen Blob (8a9f3c...) │
└─────────────────────────────────────────────────────────────┘
Datenbankschema-Änderungen
Migration 010: E2EE-Felder
-- Migration: Verschlüsselte Spalten hinzufügen, Klartext veraltert
-- 1. Chat-Nachrichten
ALTER TABLE chat_messages
ADD COLUMN content_encrypted BLOB,
ADD COLUMN content_iv BLOB,
ADD COLUMN key_version INTEGER DEFAULT 1;
-- Alten Inhalt als veraltet markieren (wird beim nächsten Zugriff neu verschlüsselt)
-- `content`-Spalte noch NICHT löschen (Rückwärtskompatibilität während Migration)
-- 2. Account-Einstellungen (API-Keys)
ALTER TABLE account_settings
ADD COLUMN anthropic_key_encrypted BLOB,
ADD COLUMN anthropic_key_iv BLOB,
ADD COLUMN openai_key_encrypted BLOB,
ADD COLUMN openai_key_iv BLOB;
-- 3. Benutzer (TOTP-Secrets)
ALTER TABLE users
ADD COLUMN totp_secret_encrypted BLOB,
ADD COLUMN totp_secret_iv BLOB;
-- 4. Recovery-Keys
CREATE TABLE key_recovery (
user_id TEXT PRIMARY KEY,
encrypted_master_key BLOB NOT NULL,
recovery_key_iv BLOB NOT NULL,
recovery_key_hint TEXT, -- optionaler Hinweis (erste 4 Zeichen: "A83Z-****-****")
created_at TEXT NOT NULL,
last_used_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- 5. Verschlüsselungsschlüssel-Rotations-Log
CREATE TABLE key_versions (
version INTEGER PRIMARY KEY,
created_at TEXT NOT NULL,
deprecated_at TEXT,
notes TEXT -- z.B. "Jährliche Rotation", "Sicherheitsvorfall"
);
INSERT INTO key_versions (version, created_at) VALUES (1, datetime('now'));
Schlüsselverwaltung
Recovery-Key-Generierung
Problem: Benutzer vergisst Passwort → masterKey verloren → alle Daten nicht wiederherstellbar.
Lösung: Recovery-Key (einmalig generiert, offline vom Benutzer gespeichert).
// Recovery-Key generieren (Browser)
async function generateRecoveryKey(masterKey: CryptoKey): Promise<string> {
// 1. Zufälligen 128-Bit-Schlüssel generieren
const recoveryBytes = crypto.getRandomValues(new Uint8Array(16));
// 2. Als menschenlesbaren String kodieren (Base32, keine mehrdeutigen Zeichen)
// Ergebnis: "A83Z-KL9P-MM4X-VN2Q-8JC7" (5 Gruppen à 4 Zeichen)
const recoveryKey = base32Encode(recoveryBytes, { groups: 5 });
// 3. AES-Schlüssel aus Recovery-Bytes ableiten
const recoveryKeyMaterial = await crypto.subtle.importKey(
"raw",
recoveryBytes,
"PBKDF2",
false,
["deriveKey"]
);
const recoveryAESKey = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: new TextEncoder().encode("citadel-recovery-v1"),
iterations: 10000, // niedrigere Iterationen (Recovery ist bereits zufällig)
hash: "SHA-256"
},
recoveryKeyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt"]
);
// 4. Master-Key exportieren (zum Verschlüsseln)
const masterKeyExport = await crypto.subtle.exportKey("raw", masterKey);
// 5. Master-Key mit Recovery-Key verschlüsseln
const iv = crypto.getRandomValues(new Uint8Array(12));
const encryptedMasterKey = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
recoveryAESKey,
masterKeyExport
);
// 6. An Server zur Speicherung senden
await fetch('/api/crm/account/recovery-key', {
method: 'POST',
body: JSON.stringify({
encrypted_master_key: base64Encode(encryptedMasterKey),
recovery_key_iv: base64Encode(iv)
})
});
// 7. Recovery-Key an Benutzer zurückgeben (NUR EINMAL ANZEIGEN)
return recoveryKey; // "A83Z-KL9P-MM4X-VN2Q-8JC7"
}
Recovery-Fluss (Passwort vergessen):
┌─────────────────────────────────────────────────────────────┐
│ Passwort-Recovery │
├─────────────────────────────────────────────────────────────┤
│ 1. Benutzer klickt "Passwort vergessen" │
│ │
│ 2. Browser zeigt: │
│ "Gib deinen Recovery-Key ein:" │
│ [____-____-____-____-____] │
│ │
│ 3. Benutzer gibt ein: A83Z-KL9P-MM4X-VN2Q-8JC7 │
│ │
│ 4. Verschlüsselten Master-Key vom Server abrufen: │
│ GET /api/crm/account/[email protected] │
│ → { encrypted_master_key, recovery_key_iv } │
│ │
│ 5. Master-Key entschlüsseln: │
│ recoveryAESKey = deriveKey(recoveryKey) │
│ masterKey = AES-GCM.decrypt( │
│ encrypted_master_key, │
│ recoveryAESKey, │
│ recovery_key_iv │
│ ) │
│ │
│ 6. Nach NEUEM Passwort fragen: │
│ "Neues Passwort festlegen:" │
│ [________] (muss sich vom alten unterscheiden) │
│ │
│ 7. Neuen authHash aus neuem Passwort ableiten │
│ │
│ 8. Server aktualisieren: │
│ PUT /api/auth/reset-password │
│ { recovery_key, new_auth_hash } │
│ │
│ 9. Server aktualisiert password_hash │
│ │
│ 10. Browser speichert masterKey in sessionStorage │
│ → Benutzer erhält Zugriff auf verschlüsselte Daten zurück │
└─────────────────────────────────────────────────────────────┘
Rate-Limiting: Max. 5 Recovery-Versuche pro Stunde (verhindert Brute-Force).
Sicherheitseigenschaften
Wogegen wir schützen
| Bedrohung | Gegenmaßnahme | Stufe |
|---|---|---|
Datenbank-Breach (gestohlene .db-Datei) |
✅ Daten verschlüsselt, keine Schlüssel | Vollständig |
| Server-Kompromittierung (Root-SSH) | ✅ Keine Master-Keys auf Server | Vollständig |
| Insider-Bedrohung (Admin-Missbrauch) | ✅ Admin kann nicht entschlüsseln | Vollständig |
| MITM-Angriff (Netzwerk-Abfangen) | ✅ HTTPS + verschlüsselter Payload | Vollständig |
| Backup-Leck (S3, Drive) | ✅ Backups enthalten nur Blobs | Vollständig |
| Rechtlicher Zwang (Gerichtsbeschluss) | ✅ Kann ohne Benutzerpasswort nicht entschlüsseln | Vollständig |
| XSS-Angriff (Master-Key stehlen) | ✅ CSP-Header + kein Inline-JS | Partiell |
| Passwort-Brute-Force | ✅ bcrypt + PBKDF2 100k Iterationen | Stark |
Wogegen wir NICHT schützen (außerhalb des Umfangs)
| Bedrohung | Benutzerverantwortung |
|---|---|
| Physischer Gerätediebstahl (entsperrter Laptop) | Bildschirmsperre, Festplatten-Verschlüsselung |
| Schädliche Browser-Erweiterung | Erweiterungen prüfen, seriöse verwenden |
| Keylogger auf Gerät | Antivirus, sicheres Gerät |
| Social Engineering (Recovery-Key-Phishing) | Sicherheitsbewusstseinstraining |
| Quantencomputing (AES-256-Bruch) | Bis ~2040 nicht machbar (NIST-Zeitplan) |
Implementierungs-Roadmap
Phase 45.1: Fundament (Issues #39) — 2 Wochen
Ziel: WebCrypto-Wrapper, Schlüsselableitung funktionsfähig.
-
frontend/src/crypto/e2ee.ts-Modul -
deriveAuthHash(password)-Funktion -
deriveMasterKey(password)-Funktion -
encrypt(plaintext, masterKey)-Funktion -
decrypt(ciphertext, iv, masterKey)-Funktion - Unit-Tests (Jest): Round-Trip-Verschlüsselung
- Sicherheits-Audit: kein Schlüssel-Leck in DevTools
Akzeptanz: Login leitet beide Schlüssel ab, Master-Key in sessionStorage.
Phase 45.2: Chat-Verschlüsselung (Issue #40) — 1 Woche
Ziel: Chat-Nachrichten E2EE.
- Migration 010:
content_encrypted,content_iv-Spalten - Workspace.jsx: vor
handlePostMessageverschlüsseln - Workspace.jsx: nach dem Abrufen der Historie entschlüsseln
- Backend: Blobs speichern, keine Klartext-Validierung
- Performance-Test: < 10 ms pro Nachricht verschlüsseln/entschlüsseln
Akzeptanz: Chat funktioniert, Server kann keine Nachrichten lesen (SQL-Abfrage zeigt Blobs).
Phase 45.3: Secrets-Verschlüsselung (Issues #41–#42) — 1 Woche
Ziel: API-Keys + TOTP-Seeds verschlüsselt.
- AccountSettings.jsx: API-Keys vor dem Speichern verschlüsseln
- AccountSettings.jsx: beim Laden entschlüsseln, in UI maskieren (
sk-***xyz) - TOTP-Setup: Seed vor dem Speichern verschlüsseln
- TOTP-Verifizierung: clientseitig (Server kann OTP nicht verifizieren)
- Migration: bestehende Klartext-Keys verschlüsseln (einmalig)
Akzeptanz: Einstellungs-UI funktioniert, Server kann keine Keys lesen.
Phase 45.4: Schlüssel-Recovery (Issues #43–#44) — 1 Woche
Ziel: Recovery-Key + DSGVO-Export.
- Recovery-Key-Generierungs-UI (beängstigende Warnung)
- PDF-Download (Recovery-Key + Anweisungen)
- Recovery-Flow-UI (Passwort vergessen → Key eingeben → neues Passwort)
- Rate-Limiting: 5 Versuche/Stunde
- Datenexport: clientseitig entschlüsseln → JSON-Download
Akzeptanz: Benutzer kann Account mit Recovery-Key wiederherstellen.
Phase 45.5: Härtung (Issues #45–#46) — 3 Tage
Ziel: CSP, Log-Bereinigung.
- CSP-Header in Nginx (
script-src 'self', kein'unsafe-inline') - Alle Inline-Event-Handler entfernen (Audit:
grep -r onClick=) - SRI-Hashes für Google Fonts
- Log-Bereinigung (
sanitize()-Funktion in logger.ts) - Audit-Skript:
grepLogs nach E-Mails/API-Keys
Akzeptanz: CSP-Verletzungen = 0, Logs sauber.
Test-Strategie
Unit-Tests (Jest)
describe('E2EE', () => {
test('deriveAuthHash ist deterministisch', async () => {
const hash1 = await deriveAuthHash('password123');
const hash2 = await deriveAuthHash('password123');
expect(hash1).toBe(hash2);
});
test('deriveMasterKey ist deterministisch', async () => {
const key1 = await deriveMasterKey('password123');
const key2 = await deriveMasterKey('password123');
const exported1 = await crypto.subtle.exportKey('raw', key1);
const exported2 = await crypto.subtle.exportKey('raw', key2);
expect(Buffer.from(exported1)).toEqual(Buffer.from(exported2));
});
test('encrypt → decrypt Round-Trip', async () => {
const masterKey = await deriveMasterKey('test');
const plaintext = 'Secret message';
const { ciphertext, iv } = await encrypt(plaintext, masterKey);
const decrypted = await decrypt(ciphertext, iv, masterKey);
expect(decrypted).toBe(plaintext);
});
test('falscher Schlüssel kann nicht entschlüsseln', async () => {
const key1 = await deriveMasterKey('password1');
const key2 = await deriveMasterKey('password2');
const { ciphertext, iv } = await encrypt('Secret', key1);
await expect(decrypt(ciphertext, iv, key2)).rejects.toThrow();
});
test('manipulierter Ciphertext schlägt fehl (GCM-Auth-Tag)', async () => {
const key = await deriveMasterKey('test');
const { ciphertext, iv } = await encrypt('Secret', key);
ciphertext[0] ^= 0xFF; // ein Bit umkehren
await expect(decrypt(ciphertext, iv, key)).rejects.toThrow();
});
});
Integrations-Tests
Registrierung → Login → Entschlüsseln:
- Mit Passwort registrieren → ausloggen → einloggen → verifizieren, dass alte Nachrichten entschlüsselt werden können
Recovery-Fluss:
- Registrieren → Recovery-Key speichern → ausloggen → Passwort vergessen → Recovery → verifizieren, dass Daten zugänglich sind
Multi-Device-Simulation:
- Auf "Gerät 1" (Chrome) einloggen → Nachricht senden
- Auf "Gerät 2" (Firefox) einloggen → verifizieren, dass dieselbe Nachricht entschlüsselt werden kann
Penetrationstest (Extern, 5.000 $ Budget)
Szenarien:
- XSS-Payload-Injektion → Versuch, Master-Key zu exfiltrieren
- Datenbankdump → verifizieren, dass kein Klartext-Chat-Inhalt vorhanden
- MITM-Angriff → verifizieren, dass verschlüsselte Blobs manipulationssicher sind (GCM-Tag)
- Recovery-Key-Brute-Force → verifizieren, dass Rate-Limiting wirksam
- Side-Channel-Timing-Angriff → verifizieren, dass PBKDF2 constant-time
Zeitplan: Nach Abschluss von #39–#42 (Q3 2026).
Migrations-Strategie
Rückwärtskompatibilität
Problem: Bestehende Benutzer haben Klartextdaten. Ihr Zugriff darf nicht unterbrochen werden.
Lösung: Schrittweise Migration.
Schritt 1: Dual-Write (Phase 45.2)
// Backend: SOWOHL Klartext als auch verschlüsselt schreiben
async function handlePostMessage(req) {
const { content, content_encrypted, content_iv } = req.body;
db.run(`
INSERT INTO chat_messages (
content, -- alter Klartext (veraltet)
content_encrypted, -- neuer E2EE-Blob
content_iv
) VALUES (?, ?, ?)
`, [
content || null, // null wenn E2EE-Client
content_encrypted,
content_iv
]);
}
// Frontend: BEIDES senden (während des Übergangs)
const { ciphertext, iv } = await encrypt(plaintext, masterKey);
await fetch('/api/chat', {
body: JSON.stringify({
content: plaintext, // für alte API
content_encrypted: ciphertext, // für neues E2EE
content_iv: iv
})
});
Schritt 2: Lese-Präferenz (Phase 45.2)
// Backend: verschlüsselt zurückgeben wenn vorhanden, sonst Klartext
const messages = db.query(`SELECT * FROM chat_messages`).all();
for (const msg of messages) {
if (msg.content_encrypted) {
// E2EE-Nachricht (bevorzugt)
yield { content_encrypted: msg.content_encrypted, content_iv: msg.content_iv };
} else {
// Legacy-Klartext (veraltet, Benutzer warnen)
yield { content: msg.content, legacy: true };
}
}
// Frontend: entschlüsseln wenn E2EE, sonst Klartext mit Warnung anzeigen
if (msg.content_encrypted) {
const plaintext = await decrypt(msg.content_encrypted, msg.content_iv, masterKey);
displayMessage(plaintext);
} else {
displayMessage(msg.content);
showWarning('Diese Nachricht wurde gesendet, bevor E2EE aktiviert wurde. Neu verschlüsseln?');
}
Schritt 3: Neu-Verschlüsselungs-Tool (Phase 45.3)
// Einstellungen → Sicherheit → "Alte Nachrichten verschlüsseln"
async function reEncryptOldMessages() {
const masterKey = getMasterKeyFromSession();
const oldMessages = await fetch('/api/chat/history?legacy=true');
for (const msg of oldMessages) {
const { ciphertext, iv } = await encrypt(msg.content, masterKey);
await fetch(`/api/chat/messages/${msg.id}/encrypt`, {
method: 'PUT',
body: JSON.stringify({ content_encrypted: ciphertext, content_iv: iv })
});
}
showSuccess('Alle Nachrichten verschlüsselt!');
}
// Backend: Nachricht aktualisieren, Klartext LÖSCHEN
app.put('/api/chat/messages/:id/encrypt', (req) => {
db.run(`
UPDATE chat_messages
SET content_encrypted = ?,
content_iv = ?,
content = NULL ← Klartext LÖSCHEN
WHERE id = ?
`, [req.content_encrypted, req.content_iv, req.params.id]);
});
Schritt 4: Klartext-Spalte löschen (Phase 46)
-- Nach 90 Tagen verifizieren, dass 0 Klartext-Nachrichten vorhanden
SELECT COUNT(*) FROM chat_messages WHERE content IS NOT NULL;
-- Falls 0, Spalte löschen
ALTER TABLE chat_messages DROP COLUMN content;
UX-Überlegungen
Beängstigende aber ehrliche Warnungen
Recovery-Key-Generierung:
┌────────────────────────────────────────────────────────────┐
│ ⚠️ KRITISCH: Speichere deinen Recovery-Key │
├────────────────────────────────────────────────────────────┤
│ Deine Daten sind mit einem Schlüssel verschlüsselt, den │
│ nur DU hast. Falls du dein Passwort UND diesen Recovery- │
│ Key verlierst, sind deine Daten DAUERHAFT VERLOREN. │
│ Wir können sie NICHT für dich wiederherstellen. │
│ │
│ Recovery-Key: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ A83Z-KL9P-MM4X-VN2Q-8JC7 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ [ PDF herunterladen ] [ Drucken ] [ Kopieren ] │
│ │
│ ☐ Ich habe diesen Recovery-Key an einem sicheren Ort gespeichert │
│ │
│ [ Weiter ] ← deaktiviert bis Checkbox angekreuzt │
└────────────────────────────────────────────────────────────┘
Erster Login nachdem E2EE aktiviert:
┌────────────────────────────────────────────────────────────┐
│ 🔒 Ende-zu-Ende-Verschlüsselung aktiviert │
├────────────────────────────────────────────────────────────┤
│ Deine Nachrichten, API-Keys und Secrets werden jetzt auf │
│ deinem Gerät verschlüsselt, bevor sie an unsere Server │
│ gesendet werden. │
│ │
│ ✓ Wir KÖNNEN deine Daten nicht lesen (selbst wenn wir wollten) │
│ ✓ Datenbankbreach = Angreifer bekommt nutzlose Blobs │
│ ✗ Passwort vergessen + Recovery-Key verloren = Daten weg │
│ │
│ [ Mehr erfahren ] [ Verstanden ] │
└────────────────────────────────────────────────────────────┘
Performance-Feedback
// Verschlüsselungs-Indikator beim Tippen anzeigen (subtil)
<textarea
placeholder="Schreib eine Nachricht... 🔒 (verschlüsselt)"
onChange={handleChange}
/>
// "Wird verschlüsselt..." Spinner vor dem Senden (< 10 ms, kaum sichtbar)
<button onClick={handleSend}>
{encrypting ? '🔐 Wird verschlüsselt...' : 'Senden'}
</button>
Compliance-Auswirkungen
DSGVO-Artikel
| Artikel | Vorher (Phase 43) | Nachher (Phase 45) | Verbesserung |
|---|---|---|---|
| Art. 25 (Datenschutz durch Design) | Klartextspeicherung | E2EE standardmäßig | ✅ Konform |
| Art. 32 (Sicherheit der Verarbeitung) | JWT + bcrypt | + AES-256 E2EE | ✅ Verbessert |
| Art. 17 (Recht auf Löschung) | Aus DB löschen | + verschlüsselt (bereits unbrauchbar) | ✅ Stärker |
| Art. 20 (Datenübertragbarkeit) | Serverexport | Clientseitiges Entschlüsseln + Export | ✅ Nutzerkontrolliert |
Marketing-Aussagen (rechtliche Prüfung erforderlich)
Erlaubt zu sagen:
- ✅ "Ende-zu-Ende-verschlüsselt"
- ✅ "Zero-Knowledge-Architektur"
- ✅ "Wir können deine Daten nicht lesen"
- ✅ "Selbst unsere Admins können deine Nachrichten nicht entschlüsseln"
Nicht erlaubt zu sagen:
- ❌ "Unhackbar" (nichts ist das)
- ❌ "Militärische Verschlüsselung" (Marketing-Unsinn, rechtlich riskant)
- ❌ "100 % sicher" (XSS, Gerätekompromittierung noch möglich)
Empfohlen:
"Deine Daten sind Ende-zu-Ende-verschlüsselt mit dem Branchenstandard AES-256-GCM. Wir verwenden eine Zero-Knowledge-Architektur: Verschlüsselung findet auf deinem Gerät statt, und wir haben niemals Zugriff auf deine Entschlüsselungsschlüssel. Selbst unsere Administratoren können deine verschlüsselten Nachrichten, API-Keys oder Secrets nicht lesen."
Kosten-Nutzen-Analyse
Kosten
| Posten | Aufwand | Risiko |
|---|---|---|
| WebCrypto-Implementierung | 2 Wochen Entwicklung | Mittel (Kryptographie ist schwer) |
| Schlüsselverwaltungs-UX | 1 Woche Design + Entwicklung | Hoch (Benutzerverwirrung) |
| Recovery-Mechanismus | 1 Woche Entwicklung | Hoch (falsche UX = Datenverlust) |
| Migration (Dual-Write) | 3 Tage Entwicklung | Gering (rückwärtskompatibel) |
| Penetrationstest | 5.000 $ | — |
| Support-Aufwand | +20 % Tickets | Mittel ("Ich habe meinen Recovery-Key verloren") |
Gesamt: ~6 Wochen Entwicklungszeit + 5.000 $ externes Audit.
Vorteile
| Vorteil | Auswirkung |
|---|---|
| Benutzervertrauen | Hoch — "Datenprivatsphäre" ist das Hauptanliegen von Unternehmen |
| Regulatorische Compliance | DSGVO-Art.-25-Compliance (für EU-Kunden erforderlich) |
| Breach-Schutz | Selbst katastrophaler Breach → verschlüsselte Blobs (minimaler Schaden) |
| Wettbewerbsvorteil | Wenige KI-Plattformen bieten E2EE (Notion, ClickUp, Monday = Klartext) |
| Enterprise-Vertrieb | Erforderlich für Gesundheitswesen, Finanzen, Rechtsbranche (HIPAA, SOC 2) |
Urteil: Vorteile überwiegen die Kosten. E2EE ist ein Muss für Enterprise-KI-Plattformen.
Erfolgsmetriken
Phase 45.1 (Fundament)
- Login-Flow: 100 % der Benutzer leiten Master-Key erfolgreich ab
- sessionStorage: 0 Schlüssel-Lecks in DevTools (Sicherheits-Audit)
- Performance: PBKDF2-Ableitung < 500 ms auf Median-Gerät
Phase 45.2 (Chat-Verschlüsselung)
- Adoptionsrate: 80 % der Nachrichten innerhalb von 30 Tagen verschlüsselt
- Performance: Verschlüsseln/Entschlüsseln < 10 ms pro Nachricht (p95)
- SQL-Audit:
SELECT content FROM chat_messages WHERE content IS NOT NULL→ 0 Zeilen (nach Migration)
Phase 45.3 (Secrets)
- API-Keys: 100 % als verschlüsselte Blobs gespeichert
- TOTP: Clientseitige Verifizierung funktioniert für 100 % der 2FA-Benutzer
- Einstellungs-UI: 0 Klartext-Keys im Netzwerk-Tab sichtbar
Phase 45.4 (Recovery)
- Recovery-Erfolgsrate: > 95 % (der Benutzer, die Recovery versuchen)
- Support-Tickets: "Recovery-Key verloren" < 5 % aller Tickets
- PDF-Downloads: 90 % der Benutzer laden Recovery-PDF herunter
Phase 45.5 (Härtung)
- CSP-Verletzungen: 0 (Browser-Konsole sauber)
- Log-Audit:
grep -E '(sk-ant|sk-proj|@)' /var/log/citadel/→ 0 Treffer - Penetrationstest: 0 kritische Befunde, < 3 mittlere Befunde
Risiken & Gegenmaßnahmen
| Risiko | Wahrscheinlichkeit | Auswirkung | Gegenmaßnahme |
|---|---|---|---|
| Benutzer vergisst Passwort + verliert Recovery-Key → Daten verloren | Mittel | KRITISCH | 1. Beängstigende Warnung beim Setup 2. Recovery-PDF automatisch per E-Mail senden 3. Optional: Recovery-Key QR-Code drucken |
| WebCrypto-Bug → beschädigte Daten | Gering | HOCH | 1. Umfangreiche Unit-Tests 2. Dual-Write während Rollout (Klartext-Backup behalten) 3. Externes Sicherheits-Audit |
| Performance-Einbruch (PBKDF2 langsam auf alten Geräten) | Mittel | Mittel | 1. Adaptive Iterationen (Gerätegeschwindigkeit erkennen) 2. Web Worker für nicht-blockierende Ableitung |
| Browser-Inkompatibilität (Safari-Bugs) | Gering | Mittel | 1. Auf Safari 15+, Chrome, Firefox testen 2. Fallback: Polyfill für alte Browser (oder blockieren) |
| Support-Aufwand ("Ich kann nicht auf meine Daten zugreifen") | Hoch | Mittel | 1. Umfassende Dokumentation 2. Recovery-Wizard-UI 3. Proaktive E-Mail: "Hast du deinen Recovery-Key gespeichert?" |
Anhang: Kryptographische Primitiven
PBKDF2-SHA256
Zweck: Brute-Force-Angriffe auf Passwörter verlangsamen.
Parameter:
- Iterationen: 100.000 (OWASP-2025-Empfehlung)
- Salt: Fest pro Ableitungstyp (
citadel-auth-v1,citadel-master-v1) - Ausgabe: 256-Bit-Schlüssel
Sicherheit: Bei 100.000 Iterationen kann ein Angreifer ~1.000 Passwörter/Sekunde auf High-End-GPU ausprobieren (vs. 1 Mio./Sekunde für reines SHA256).
AES-GCM
Zweck: Authentifizierte Verschlüsselung (Vertraulichkeit + Integrität).
Parameter:
- Schlüsselgröße: 256-Bit
- IV-Größe: 96-Bit (12 Bytes, zufällig pro Nachricht)
- Auth-Tag: 128-Bit (16 Bytes, an Ciphertext angehängt)
Sicherheit: AES-256 ist bis ~2040 quantenresistent (NIST-Schätzung). GCM-Modus verhindert Manipulation (jedes Bit umkehren → Entschlüsselung schlägt fehl).
bcrypt
Zweck: Passwort-Hashing (serverseitig).
Parameter:
- Kosten: 12 Runden (4.096 Iterationen)
- Salt: Zufällig 128-Bit pro Benutzer
Warum doppeltes Hashing? Browser sendet bcrypt(PBKDF2(password)) → Server speichert bcrypt(receivedHash). Selbst wenn Server-DB durchsickert, muss Angreifer ZWEI bcrypt-Hashes umkehren.
Verfasst von Sentinel (Security Architect). Zur Implementierung genehmigt: CEO (2026-04-24).