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


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:

  1. Datenbank-Breach — Angreifer lädt citadel.db herunter → liest alle Nachrichten, API-Keys, TOTP-Seeds
  2. Server-Kompromittierung — Angreifer erhält SSH-Zugang → sqlite3 citadel.db .dump → vollständiger Datenexport
  3. Insider-Bedrohung — Admin mit Root-Zugang → liest Benutzerkonversationen
  4. Backup-Leck — unverschlüsseltes DB-Backup in die Cloud hochgeladen → exponiert
  5. 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 authHashkann 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.

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.

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.

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.

Akzeptanz: Benutzer kann Account mit Recovery-Key wiederherstellen.

Phase 45.5: Härtung (Issues #45–#46) — 3 Tage

Ziel: CSP, Log-Bereinigung.

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

  1. Registrierung → Login → Entschlüsseln:

    • Mit Passwort registrieren → ausloggen → einloggen → verifizieren, dass alte Nachrichten entschlüsselt werden können
  2. Recovery-Fluss:

    • Registrieren → Recovery-Key speichern → ausloggen → Passwort vergessen → Recovery → verifizieren, dass Daten zugänglich sind
  3. 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:

  1. XSS-Payload-Injektion → Versuch, Master-Key zu exfiltrieren
  2. Datenbankdump → verifizieren, dass kein Klartext-Chat-Inhalt vorhanden
  3. MITM-Angriff → verifizieren, dass verschlüsselte Blobs manipulationssicher sind (GCM-Tag)
  4. Recovery-Key-Brute-Force → verifizieren, dass Rate-Limiting wirksam
  5. 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:

Nicht erlaubt zu sagen:

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)

Phase 45.2 (Chat-Verschlüsselung)

Phase 45.3 (Secrets)

Phase 45.4 (Recovery)

Phase 45.5 (Härtung)


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:

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:

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:

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).