Phase 45: Architektura Zero-Knowledge E2EE

Arc OS — „Nie możemy odczytać Twoich danych, nawet gdybyśmy chcieli" Autor: Sentinel (Security Architect) | Data: 2026-04-24 Status: DONE ✅ (2026-04-28) — Zgłoszenia #16–#20 ukończone Zatwierdzone przez: CEO

Lista kontrolna implementacji


Problem

Stan obecny (Phase 43): Serwer przechowuje dane użytkownika w postaci zwykłego tekstu.

-- data/citadel.db (SQLite, niezaszyfrowany plik)
CREATE TABLE chat_messages (
  content TEXT NOT NULL  -- ❌ "sk-ant-abc123xyz (mój klucz API)"
);

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" (seed 2FA)
);

Scenariusze ataku:

  1. Naruszenie bazy danych — atakujący pobiera citadel.db → odczytuje wszystkie wiadomości, klucze API, seedy TOTP
  2. Kompromitacja serwera — atakujący uzyskuje dostęp SSH → sqlite3 citadel.db .dump → pełny eksport danych
  3. Zagrożenie wewnętrzne — admin z dostępem root → odczytuje rozmowy użytkowników
  4. Wyciek backupu — niezaszyfrowany backup DB wgrany do chmury → ujawniony
  5. Przymus prawny — nakaz sądowy zmusza do przekazania danych → wszystkie dane użytkownika czytelne

Obecny werdykt: Bezpieczne dla multi-tenancy (użytkownicy nie mogą dostęp do danych innych), NIE bezpieczne dla prywatności użytkownika (serwer może odczytać wszystko).


Wizja: Architektura Zero-Knowledge

Zasada: Serwer jest niezaufany. Nawet przy pełnym dostępie SSH + dostępie do bazy danych, nie możemy odszyfrować danych użytkownika.

Co się zmienia:

PRZED (Phase 43):
Użytkownik pisze wiadomość → serwer przechowuje zwykły tekst → admini mogą odczytać ❌

PO (Phase 45):
Użytkownik pisze wiadomość → przeglądarka szyfruje → serwer przechowuje blob → admini NIE MOGĄ odczytać ✅

Obietnica:

„Twoje dane są zaszyfrowane kluczem pochodnym od Twojego hasła. Nie mamy Twojego hasła, więc nie możemy odszyfrować Twoich danych — nawet gdybyśmy chcieli."

Model: Signal Protocol (uproszczony), ProtonMail, 1Password.


Analiza decyzji: Modele szyfrowania

Opcja A: Szyfrowanie po stronie serwera (tylko at-rest)

Użytkownik → serwer → szyfruj kluczem serwera → przechowaj w DB
Kryterium Ocena Szczegóły
Bezpieczeństwo przed osobą z zewnątrz Średnie Plik DB zaszyfrowany, ale serwer ma klucz → dostęp root = można odszyfrować
Bezpieczeństwo przed adminem ŻADNE Admin ma klucz serwera → pełny dostęp
Zgodność (RODO) Słaba „Ochrona danych" ale nie zero-knowledge
Złożoność Niska SQLCipher lub PRAGMA key
Odzyskiwanie klucza Łatwe Serwer ma klucz → nie wymaga działania użytkownika

Werdykt: Chroni przed skradzionym dyskiem twardym, NIE przed kompromitacją serwera ani zagrożeniem wewnętrznym.

Opcja B: Szyfrowanie na poziomie aplikacji (po stronie serwera)

Użytkownik → serwer → szyfruj kluczem vault → przechowaj w DB
Serwer ma klucz vault (AES-256-GCM w vault.json)
Kryterium Ocena Szczegóły
Bezpieczeństwo przed osobą z zewnątrz Średnie Lepsze niż zwykły tekst, ale klucz vault na tym samym serwerze
Bezpieczeństwo przed adminem ŻADNE Admin ma vault.json → odszyfruj wszystko
Zgodność Słaba Nadal nie zero-knowledge
Złożoność Średnia Wrappery szyfrowania na poziomie pola
Odzyskiwanie klucza Łatwe Zarządzane przez serwer

Werdykt: Marginalnie lepsze od Opcji A. Nadal nie przechodzi testu „zagrożenia wewnętrznego".

Opcja C: Szyfrowanie end-to-end (Zero-Knowledge) — WYBRANA

Użytkownik → przeglądarka szyfruje kluczem głównym (NIGDY nie wysyłanym do serwera) → serwer przechowuje blob
Serwer nie może odszyfrować (brak klucza)
Kryterium Ocena Szczegóły
Bezpieczeństwo przed osobą z zewnątrz WYSOKIE Zaszyfrowane dane + brak klucza = bezużyteczne
Bezpieczeństwo przed adminem WYSOKIE Admin ma bazę danych, ale nie może odszyfrować
Zgodność (RODO) DOSKONAŁA Prawdziwa minimalizacja danych (Art. 25)
Złożoność WYSOKA WebCrypto API, zarządzanie kluczami, UX odzyskiwania
Odzyskiwanie klucza TRUDNE Użytkownik traci hasło = utracone dane (wymaga klucza odzyskiwania)

Werdykt: Jedyna opcja zapewniająca zero-knowledge. Koszt złożoności wart dla prywatności użytkownika.


Przegląd architektury

1. Dwu-kluczowe wyprowadzanie (Projekt Split-Brain)

Problem: Hasło wysyłane do serwera do uwierzytelniania. Jak wyprowadzić klucz szyfrowania bez wysyłania hasła?

Rozwiązanie: Wyprowadź DWA klucze z hasła z różnymi sołami.

Hasło użytkownika: "MojeHasło123!"
      │
      ├─ PBKDF2(hasło, salt="citadel-auth-v1", 100k iter)
      │     ↓
      │  authBits (256-bit)
      │     ↓
      │  bcrypt(authBits, cost=12) → authHash
      │     ↓
      │  WYŚLIJ DO SERWERA (do weryfikacji logowania)
      │
      └─ PBKDF2(hasło, salt="citadel-master-v1", 100k iter)
            ↓
         masterKey (klucz szyfrowania AES-256-GCM)
            ↓
         POZOSTAJE W PRZEGLĄDARCE (sessionStorage)
         NIGDY nie wysyłany do serwera

Właściwość bezpieczeństwa: Kompromitacja serwera → atakujący dostaje authHashnie może wyprowadzić masterKey (różna sól = różne wyjście).

Kod (frontend/src/crypto/e2ee.ts):

// Hash auth (wysyłany do serwera)
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
  );
  
  // Serwer bchryptuje to ponownie do przechowywania
  return Buffer.from(bits).toString("hex");
}

// Klucz główny (przechowywany w przeglądarce)
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,  // NIE wyciągalny (nie może wyciec)
    ["encrypt", "decrypt"]
  );
  
  return masterKey;
}

2. Przepływ rejestracji

┌─────────────────────────────────────────────────────────────┐
│ Rejestracja użytkownika (przeglądarka)                      │
├─────────────────────────────────────────────────────────────┤
│  1. Użytkownik podaje: email, hasło                         │
│                                                              │
│  2. Przeglądarka wyprowadza:                               │
│     authHash = PBKDF2(hasło, "auth-salt") → bcrypt         │
│     masterKey = PBKDF2(hasło, "master-salt") → klucz AES   │
│                                                              │
│  3. POST /api/auth/register                                 │
│     {                                                        │
│       email: "[email protected]",                           │
│       authHash: "2a12...bcrypt..."  ← przechowywane w DB   │
│     }                                                        │
│                                                              │
│  4. Serwer:                                                 │
│     - bcrypt(authHash) → password_hash (podwójny hash)     │
│     - INSERT INTO users (email, password_hash)             │
│     - Wyślij email weryfikacyjny                            │
│                                                              │
│  5. Przeglądarka:                                           │
│     - Przechowaj masterKey w sessionStorage                │
│     - Wygeneruj klucz odzyskiwania (zaszyfruj masterKey)   │
│     - Pokaż klucz odzykiwania użytkownikowi: ⚠️ ZAPISZ!   │
│       "A83Z-KL9P-MM4X-VN2Q-8JC7"                           │
│     - Użytkownik pobiera PDF                               │
│                                                              │
│  6. POST /api/crm/account/recovery-key                      │
│     {                                                        │
│       encrypted_master_key: base64(...),  ← blob AES-GCM   │
│       recovery_key_iv: base64(...)                         │
│     }                                                        │
└─────────────────────────────────────────────────────────────┘

3. Przepływ logowania

┌─────────────────────────────────────────────────────────────┐
│ Logowanie użytkownika (przeglądarka)                        │
├─────────────────────────────────────────────────────────────┤
│  1. Użytkownik podaje: email, hasło                         │
│                                                              │
│  2. Przeglądarka wyprowadza:                               │
│     authHash = PBKDF2(hasło, "auth-salt") → bcrypt         │
│     masterKey = PBKDF2(hasło, "master-salt") → klucz AES   │
│                                                              │
│  3. POST /api/auth/login                                    │
│     {                                                        │
│       email: "[email protected]",                           │
│       authHash: "2a12..."                                  │
│     }                                                        │
│                                                              │
│  4. Serwer:                                                 │
│     - Pobierz user.password_hash z DB                      │
│     - bcrypt.compare(authHash, password_hash)              │
│     - Jeśli pasuje: wygeneruj token JWT                    │
│     - Zwróć { token, user_id }                             │
│                                                              │
│  5. Przeglądarka:                                           │
│     - Przechowaj JWT w localStorage (do auth API)          │
│     - Przechowaj masterKey w sessionStorage (do deszyfrowania) │
│     - Gotowy do szyfrowania/deszyfrowania danych           │
└─────────────────────────────────────────────────────────────┘

4. Szyfruj i wyślij wiadomość

┌─────────────────────────────────────────────────────────────┐
│ Wyślij wiadomość czatu (przeglądarka)                       │
├─────────────────────────────────────────────────────────────┤
│  1. Użytkownik pisze: "Deploy na produkcję z sk-ant-xyz123" │
│                                                              │
│  2. Przeglądarka szyfruje:                                  │
│     plaintext = "Deploy na produkcję z sk-ant-xyz123"      │
│     iv = crypto.getRandomValues(12 bytes)  ← losowy nonce  │
│     ciphertext = AES-GCM.encrypt(plaintext, masterKey, iv) │
│                                                              │
│  3. POST /api/crm/projects/arc-v2/chat                      │
│     {                                                        │
│       worker_id: "developer",                              │
│       content_encrypted: "8a9f3c...",  ← blob base64       │
│       content_iv: "7b2e1a..."          ← IV base64         │
│       // BRAK pola content w zwykłym tekście               │
│     }                                                        │
│                                                              │
│  4. Serwer:                                                 │
│     - Weryfikuj JWT (użytkownik autoryzowany)              │
│     - INSERT INTO chat_messages (                          │
│         project_name,                                       │
│         worker_id,                                          │
│         role = 'user',                                      │
│         content_encrypted = BLOB,  ← nieprzezroczysty dla serwera │
│         content_iv = BLOB,                                  │
│         timestamp                                           │
│       )                                                      │
│     - Zwróć { message_id }                                 │
│                                                              │
│  5. Serwer NIE MOŻE odczytać "sk-ant-xyz123" — jest zaszyfrowany │
└─────────────────────────────────────────────────────────────┘

5. Odbierz i odszyfruj wiadomość

┌─────────────────────────────────────────────────────────────┐
│ Pobierz historię czatu (przeglądarka)                       │
├─────────────────────────────────────────────────────────────┤
│  1. GET /api/crm/projects/arc-v2/chat/history               │
│     Authorization: Bearer <JWT>                             │
│                                                              │
│  2. Serwer:                                                 │
│     - Weryfikuj JWT + własność (canAccessProject)          │
│     - SELECT content_encrypted, content_iv, timestamp      │
│       FROM chat_messages                                    │
│       WHERE project_name = 'arc-v2'                        │
│       ORDER BY timestamp DESC                               │
│       LIMIT 50                                              │
│     - Zwróć [{ content_encrypted, content_iv, ... }]       │
│                                                              │
│  3. Przeglądarka odszyfrowuje KAŻDĄ wiadomość:             │
│     for (const msg of messages) {                          │
│       const ciphertext = base64Decode(msg.content_encrypted);│
│       const iv = base64Decode(msg.content_iv);             │
│       const plaintext = AES-GCM.decrypt(                   │
│         ciphertext,                                         │
│         masterKey,  ← z sessionStorage                     │
│         iv                                                  │
│       );                                                    │
│       displayMessage(plaintext);                           │
│     }                                                        │
│                                                              │
│  4. Użytkownik widzi: "Deploy na produkcję z sk-ant-xyz123" │
│     Serwer widział: bez znaczenia blob (8a9f3c...)         │
└─────────────────────────────────────────────────────────────┘

Zmiany schematu bazy danych

Migracja 010: Pola E2EE

-- Migracja: Dodaj zaszyfrowane kolumny, zdeprecjonuj zwykły tekst

-- 1. Wiadomości czatu
ALTER TABLE chat_messages 
  ADD COLUMN content_encrypted BLOB,
  ADD COLUMN content_iv BLOB,
  ADD COLUMN key_version INTEGER DEFAULT 1;

-- Oznacz stary content jako deprecated (zostanie ponownie zaszyfrowany przy dostępie)
-- NIE USUWAJ jeszcze kolumny `content` (kompatybilność wsteczna podczas migracji)

-- 2. Ustawienia konta (klucze API)
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. Użytkownicy (seedy TOTP)
ALTER TABLE users
  ADD COLUMN totp_secret_encrypted BLOB,
  ADD COLUMN totp_secret_iv BLOB;

-- 4. Klucze odzykiwania
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,  -- opcjonalna wskazówka (pierwsze 4 znaki: "A83Z-****-****")
  created_at TEXT NOT NULL,
  last_used_at TEXT,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

-- 5. Dziennik rotacji kluczy szyfrowania
CREATE TABLE key_versions (
  version INTEGER PRIMARY KEY,
  created_at TEXT NOT NULL,
  deprecated_at TEXT,
  notes TEXT  -- np. "Roczna rotacja", "Incydent bezpieczeństwa"
);

INSERT INTO key_versions (version, created_at) VALUES (1, datetime('now'));

Zarządzanie kluczami

Generowanie klucza odzykiwania

Problem: Użytkownik zapomina hasło → masterKey utracony → wszystkie dane nieodwracalne.

Rozwiązanie: Klucz odzykiwania (generowany raz, przechowywany offline przez użytkownika).

// Generuj klucz odzykiwania (przeglądarka)
async function generateRecoveryKey(masterKey: CryptoKey): Promise<string> {
  // 1. Wygeneruj losowy 128-bitowy klucz
  const recoveryBytes = crypto.getRandomValues(new Uint8Array(16));
  
  // 2. Koduj jako czytelny dla człowieka ciąg (Base32, bez niejednoznacznych znaków)
  // Wynik: "A83Z-KL9P-MM4X-VN2Q-8JC7" (5 grup po 4 znaki)
  const recoveryKey = base32Encode(recoveryBytes, { groups: 5 });
  
  // 3. Wyprowadź klucz AES z bajtów odzykiwania
  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,  // mniej iteracji (klucz odzykiwania jest już losowy)
      hash: "SHA-256"
    },
    recoveryKeyMaterial,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt"]
  );
  
  // 4. Eksportuj klucz główny (do szyfrowania)
  const masterKeyExport = await crypto.subtle.exportKey("raw", masterKey);
  
  // 5. Zaszyfruj klucz główny kluczem odzykiwania
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encryptedMasterKey = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    recoveryAESKey,
    masterKeyExport
  );
  
  // 6. Wyślij do serwera do przechowywania
  await fetch('/api/crm/account/recovery-key', {
    method: 'POST',
    body: JSON.stringify({
      encrypted_master_key: base64Encode(encryptedMasterKey),
      recovery_key_iv: base64Encode(iv)
    })
  });
  
  // 7. Zwróć klucz odzykiwania użytkownikowi (POKAZUJ TYLKO RAZ)
  return recoveryKey;  // "A83Z-KL9P-MM4X-VN2Q-8JC7"
}

Przepływ odzykiwania (zapomniane hasło):

┌─────────────────────────────────────────────────────────────┐
│ Odzykiwanie hasła                                           │
├─────────────────────────────────────────────────────────────┤
│  1. Użytkownik klika „Zapomniałem hasła"                    │
│                                                              │
│  2. Przeglądarka pokazuje:                                  │
│     „Podaj klucz odzykiwania:"                              │
│     [____-____-____-____-____]                             │
│                                                              │
│  3. Użytkownik wpisuje: A83Z-KL9P-MM4X-VN2Q-8JC7           │
│                                                              │
│  4. Pobierz zaszyfrowany klucz główny z serwera:           │
│     GET /api/crm/account/[email protected]     │
│     → { encrypted_master_key, recovery_key_iv }            │
│                                                              │
│  5. Odszyfruj klucz główny:                                 │
│     recoveryAESKey = deriveKey(recoveryKey)                │
│     masterKey = AES-GCM.decrypt(                           │
│       encrypted_master_key,                                 │
│       recoveryAESKey,                                       │
│       recovery_key_iv                                       │
│     )                                                        │
│                                                              │
│  6. Poproś o NOWE hasło:                                    │
│     „Ustaw nowe hasło:"                                     │
│     [________] (musi różnić się od starego)                │
│                                                              │
│  7. Wyprowadź nowy authHash z nowego hasła                  │
│                                                              │
│  8. Zaktualizuj serwer:                                     │
│     PUT /api/auth/reset-password                            │
│     { recovery_key, new_auth_hash }                        │
│                                                              │
│  9. Serwer aktualizuje password_hash                        │
│                                                              │
│ 10. Przeglądarka przechowuje masterKey w sessionStorage     │
│     → Użytkownik odzyskuje dostęp do zaszyfrowanych danych │
└─────────────────────────────────────────────────────────────┘

Ograniczenie prób: Maks. 5 prób odykiwania na godzinę (ochrona przed brute-force).


Właściwości bezpieczeństwa

Przed czym chronimy

Zagrożenie Łagodzenie Poziom
Naruszenie bazy danych (skradziony plik .db) ✅ Dane zaszyfrowane, brak kluczy Pełne
Kompromitacja serwera (root SSH) ✅ Brak kluczy głównych na serwerze Pełne
Zagrożenie wewnętrzne (nadużycie admina) ✅ Admin nie może odszyfrować Pełne
Atak MITM (przechwycenie sieci) ✅ HTTPS + zaszyfrowany payload Pełne
Wyciek backupu (S3, Drive) ✅ Backupy zawierają tylko blob Pełne
Przymus prawny (nakaz sądowy) ✅ Nie można odszyfrować bez hasła użytkownika Pełne
Atak XSS (kradzież klucza głównego) ✅ Nagłówki CSP + brak inline JS Częściowe
Brute-force hasła ✅ bcrypt + PBKDF2 100k iteracji Silne

Przed czym NIE chronimy (poza zakresem)

Zagrożenie Odpowiedzialność użytkownika
Kradzież fizycznego urządzenia (odblokowany laptop) Blokada ekranu, szyfrowanie całego dysku
Złośliwe rozszerzenie przeglądarki Przeglądaj rozszerzenia, używaj zaufanych
Keylogger na urządzeniu Antywirus, bezpieczne urządzenie
Inżynieria społeczna (phishing klucza odzykiwania) Szkolenie z bezpieczeństwa
Obliczenia kwantowe (złamanie AES-256) Niewykonalne do ~2040 (harmonogram NIST)

Roadmapa implementacji

Phase 45.1: Fundament (Zgłoszenia #39) — 2 tygodnie

Cel: Wrapper WebCrypto, wyprowadzanie kluczy działa.

Akceptacja: Logowanie wyprowadza oba klucze, klucz główny w sessionStorage.

Phase 45.2: Szyfrowanie czatu (Zgłoszenie #40) — 1 tydzień

Cel: E2EE wiadomości czatu.

Akceptacja: Czat działa, serwer nie może odczytać wiadomości (zapytanie SQL pokazuje blob).

Phase 45.3: Szyfrowanie sekretów (Zgłoszenia #41–#42) — 1 tydzień

Cel: Klucze API + seedy TOTP zaszyfrowane.

Akceptacja: UI ustawień działa, serwer nie może odczytać kluczy.

Phase 45.4: Odzykiwanie kluczy (Zgłoszenia #43–#44) — 1 tydzień

Cel: Klucz odzykiwania + eksport RODO.

Akceptacja: Użytkownik może odzyskać konto kluczem odykiwania.

Phase 45.5: Utwardzanie (Zgłoszenia #45–#46) — 3 dni

Cel: CSP, sanityzacja logów.

Akceptacja: Naruszenia CSP = 0, logi czyste.


Strategia testowania

Testy jednostkowe (Jest)

describe('E2EE', () => {
  test('deriveAuthHash jest deterministyczny', async () => {
    const hash1 = await deriveAuthHash('password123');
    const hash2 = await deriveAuthHash('password123');
    expect(hash1).toBe(hash2);
  });

  test('deriveMasterKey jest deterministyczny', 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('szyfrowanie → deszyfrowanie 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('zły klucz nie może odszyfrować', 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('zmodyfikowany ciphertext zawodzi (tag auth GCM)', async () => {
    const key = await deriveMasterKey('test');
    const { ciphertext, iv } = await encrypt('Secret', key);
    ciphertext[0] ^= 0xFF;  // odwróć jeden bit
    await expect(decrypt(ciphertext, iv, key)).rejects.toThrow();
  });
});

Testy integracyjne

  1. Rejestracja → Logowanie → Deszyfrowanie:

    • Zarejestruj z hasłem → wyloguj → zaloguj → zweryfikuj możliwość odszyfrowania starych wiadomości
  2. Przepływ odykiwania:

    • Zarejestruj → zapisz klucz odykiwania → wyloguj → zapomniane hasło → odykaj → zweryfikuj dostępność danych
  3. Symulacja multi-device:

    • Zaloguj na „urządzeniu 1" (Chrome) → wyślij wiadomość
    • Zaloguj na „urządzeniu 2" (Firefox) → zweryfikuj możliwość odszyfrowania tej samej wiadomości

Test penetracyjny (Zewnętrzny, budżet $5k)

Scenariusze:

  1. Wstrzyknięcie payloadu XSS → próba eksfiltracji klucza głównego
  2. Dump bazy danych → weryfikacja braku treści czatu w zwykłym tekście
  3. Atak MITM → weryfikacja odporności zaszyfrowanych blobów na manipulację (tag GCM)
  4. Brute-force klucza odykiwania → weryfikacja skuteczności ograniczenia prób
  5. Atak timing side-channel → weryfikacja stałego czasu PBKDF2

Termin: Po ukończeniu #39–#42 (Q3 2026).


Strategia migracji

Kompatybilność wsteczna

Problem: Istniejący użytkownicy mają dane w zwykłym tekście. Nie możemy zepsuć ich dostępu.

Rozwiązanie: Stopniowa migracja.

Krok 1: Podwójny zapis (Phase 45.2)

// Backend: zapisz OBA zwykły tekst i zaszyfrowany
async function handlePostMessage(req) {
  const { content, content_encrypted, content_iv } = req.body;
  
  db.run(`
    INSERT INTO chat_messages (
      content,             -- stary zwykły tekst (deprecated)
      content_encrypted,   -- nowy blob E2EE
      content_iv
    ) VALUES (?, ?, ?)
  `, [
    content || null,  // null jeśli klient E2EE
    content_encrypted,
    content_iv
  ]);
}

// Frontend: wysyłaj OBA (podczas przejścia)
const { ciphertext, iv } = await encrypt(plaintext, masterKey);
await fetch('/api/chat', {
  body: JSON.stringify({
    content: plaintext,        // dla starego API
    content_encrypted: ciphertext,  // dla nowego E2EE
    content_iv: iv
  })
});

Krok 2: Preferencja odczytu (Phase 45.2)

// Backend: zwróć zaszyfrowane jeśli dostępne, inaczej zwykły tekst
const messages = db.query(`SELECT * FROM chat_messages`).all();
for (const msg of messages) {
  if (msg.content_encrypted) {
    // Wiadomość E2EE (preferowana)
    yield { content_encrypted: msg.content_encrypted, content_iv: msg.content_iv };
  } else {
    // Legacy zwykły tekst (deprecated, ostrzeż użytkownika)
    yield { content: msg.content, legacy: true };
  }
}

// Frontend: odszyfruj jeśli E2EE, inaczej pokaż zwykły tekst z ostrzeżeniem
if (msg.content_encrypted) {
  const plaintext = await decrypt(msg.content_encrypted, msg.content_iv, masterKey);
  displayMessage(plaintext);
} else {
  displayMessage(msg.content);
  showWarning('Ta wiadomość została wysłana przed włączeniem E2EE. Ponownie ją zaszyfrować?');
}

Krok 3: Narzędzie ponownego szyfrowania (Phase 45.3)

// Ustawienia → Bezpieczeństwo → „Zaszyfruj stare wiadomości"
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('Wszystkie wiadomości zaszyfrowane!');
}

// Backend: zaktualizuj wiadomość, WYMAŻ zwykły tekst
app.put('/api/chat/messages/:id/encrypt', (req) => {
  db.run(`
    UPDATE chat_messages
    SET content_encrypted = ?,
        content_iv = ?,
        content = NULL  ← WYMAŻ zwykły tekst
    WHERE id = ?
  `, [req.content_encrypted, req.content_iv, req.params.id]);
});

Krok 4: Usuń kolumnę zwykłego tekstu (Phase 46)

-- Po 90 dniach, zweryfikuj 0 wiadomości w zwykłym tekście
SELECT COUNT(*) FROM chat_messages WHERE content IS NOT NULL;
-- Jeśli 0, usuń kolumnę

ALTER TABLE chat_messages DROP COLUMN content;

Uwagi dotyczące UX

Przerażające, ale uczciwe ostrzeżenia

Generowanie klucza odykiwania:

┌────────────────────────────────────────────────────────────┐
│  ⚠️ KRYTYCZNE: Zapisz swój klucz odykiwania               │
├────────────────────────────────────────────────────────────┤
│  Twoje dane są zaszyfrowane kluczem, który tylko TY masz.  │
│  Jeśli utracisz hasło ORAZ ten klucz odykiwania, Twoje    │
│  dane zostaną TRWALE UTRACONE. NIE możemy ich odzyskać.   │
│                                                             │
│  Klucz odykiwania:                                         │
│  ┌──────────────────────────────────────────────────────┐ │
│  │  A83Z-KL9P-MM4X-VN2Q-8JC7                            │ │
│  └──────────────────────────────────────────────────────┘ │
│                                                             │
│  [ Pobierz PDF ]  [ Drukuj ]  [ Kopiuj ]                   │
│                                                             │
│  ☐ Zapisałem/am ten klucz odykiwania w bezpiecznym miejscu│
│                                                             │
│  [ Kontynuuj ]  ← wyłączony dopóki checkbox niezaznaczony  │
└────────────────────────────────────────────────────────────┘

Pierwsze logowanie po włączeniu E2EE:

┌────────────────────────────────────────────────────────────┐
│  🔒 Szyfrowanie end-to-end włączone                        │
├────────────────────────────────────────────────────────────┤
│  Twoje wiadomości, klucze API i sekrety są teraz szyfrowane│
│  na Twoim urządzeniu przed wysłaniem na nasze serwery.     │
│                                                             │
│  ✓ NIE MOŻEMY odczytać Twoich danych (nawet gdybyśmy chcieli) │
│  ✓ Naruszenie bazy danych = atakujący dostaje bezużyteczne blob │
│  ✗ Zapomniane hasło + utracony klucz odykiwania = utrata danych │
│                                                             │
│  [ Dowiedz się więcej ]  [ Rozumiem ]                       │
└────────────────────────────────────────────────────────────┘

Wpływ na zgodność

Artykuły RODO

Artykuł Przed (Phase 43) Po (Phase 45) Poprawa
Art. 25 (Ochrona danych w fazie projektowania) Przechowywanie w zwykłym tekście E2EE domyślnie ✅ Zgodny
Art. 32 (Bezpieczeństwo przetwarzania) JWT + bcrypt + AES-256 E2EE ✅ Wzmocniony
Art. 17 (Prawo do usunięcia danych) Usuń z DB + zaszyfrowane (już bezużyteczne) ✅ Silniejszy
Art. 20 (Przenoszenie danych) Eksport z serwera Deszyfrowanie po stronie klienta + eksport ✅ Kontrolowany przez użytkownika

Metryki sukcesu

Phase 45.1 (Fundament)

Phase 45.2 (Szyfrowanie czatu)

Phase 45.3 (Sekrety)

Phase 45.4 (Odykiwanie)

Phase 45.5 (Utwardzanie)


Ryzyka i łagodzenie

Ryzyko Prawdopodobieństwo Wpływ Łagodzenie
Użytkownik zapomina hasła + traci klucz odykiwania → utrata danych Średnie KRYTYCZNY 1. Przerażające ostrzeżenie podczas konfiguracji
2. Automatyczne wysyłanie PDF z kluczem emailem
3. Opcjonalnie: drukowanie QR kodu klucza odykiwania
Błąd WebCrypto → uszkodzone dane Niskie WYSOKIE 1. Obszerne testy jednostkowe
2. Podwójny zapis podczas wdrożenia (zachowaj backup zwykłego tekstu)
3. Zewnętrzny audyt bezpieczeństwa
Degradacja wydajności (PBKDF2 wolne na starych urządzeniach) Średnie Średnie 1. Adaptacyjne iteracje (wykryj prędkość urządzenia)
2. Web Worker dla nieblokującego wyprowadzania
Niekompatybilność przeglądarki (błędy Safari) Niskie Średnie 1. Testuj na Safari 15+, Chrome, Firefox
2. Fallback: polyfill dla starych przeglądarek (lub blokuj je)
Obciążenie supportu („Nie mogę uzyskać dostępu do danych") Wysokie Średnie 1. Obszerna dokumentacja
2. UI kreatora odykiwania
3. Proaktywny email: „Czy zapisałeś/aś swój klucz odykiwania?"

Appendix: Prymitywy kryptograficzne

PBKDF2-SHA256

Cel: Spowalnianie ataków brute-force na hasła.

Parametry:

Bezpieczeństwo: Przy 100k iteracjach, atakujący może próbować ~1000 haseł/sek na high-end GPU (vs 1M/sek dla plain SHA256).

AES-GCM

Cel: Uwierzytelnione szyfrowanie (poufność + integralność).

Parametry:

Bezpieczeństwo: AES-256 jest odporny kwantowo do ~2040 (szacunki NIST). Tryb GCM zapobiega manipulacji (dowolne odwrócenie bitu → deszyfrowanie zawodzi).

bcrypt

Cel: Haszowanie haseł (po stronie serwera).

Parametry:

Dlaczego podwójny hash? Przeglądarka wysyła bcrypt(PBKDF2(hasło)) → serwer przechowuje bcrypt(receivedHash). Nawet jeśli DB serwera wycieknie, atakujący musi odwrócić DWA hashe bcrypt.


Autorstwa Sentinel (Security Architect). Zatwierdzone do implementacji: CEO (2026-04-24).