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
- ✅ 45.1 — Wrapper WebCrypto (
frontend/src/crm/crypto/e2ee.ts, 214 linii) - ✅ 45.2 — Szyfrowanie pól vault (
shared/vault.ts: encryptField/decryptField/isFieldEncrypted) - ✅ 45.3 — Szyfrowanie wiadomości czatu at-rest (migracja 015, db.ts auto-encrypt/decrypt)
- ✅ 45.4 — Klucze odzyskiwania (migracja 016, 4 endpointy API, UI RecoveryKeySection)
- ✅ 45.5 — Nagłówki CSP/bezpieczeństwa + sanityzator PII (
shared/pii-sanitizer.ts) - 🔲 45.6 — Zaawansowane (synchronizacja multi-device, forward secrecy, retencja danych) — P2 odroczone
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:
- Naruszenie bazy danych — atakujący pobiera
citadel.db→ odczytuje wszystkie wiadomości, klucze API, seedy TOTP - Kompromitacja serwera — atakujący uzyskuje dostęp SSH →
sqlite3 citadel.db .dump→ pełny eksport danych - Zagrożenie wewnętrzne — admin z dostępem root → odczytuje rozmowy użytkowników
- Wyciek backupu — niezaszyfrowany backup DB wgrany do chmury → ujawniony
- 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 authHash → nie 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.
- Moduł
frontend/src/crypto/e2ee.ts - Funkcja
deriveAuthHash(password) - Funkcja
deriveMasterKey(password) - Funkcja
encrypt(plaintext, masterKey) - Funkcja
decrypt(ciphertext, iv, masterKey) - Testy jednostkowe (Jest): szyfrowanie round-trip
- Audyt bezpieczeństwa: brak wycieku klucza w DevTools
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.
- Migracja 010: kolumny
content_encrypted,content_iv - Workspace.jsx: szyfruj przed
handlePostMessage - Workspace.jsx: deszyfruj po pobraniu historii
- Backend: przechowuj blob, bez walidacji zwykłego tekstu
- Test wydajności: < 10ms na szyfrowanie/deszyfrowanie wiadomości
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.
- AccountSettings.jsx: szyfruj klucze API przed zapisem
- AccountSettings.jsx: deszyfruj przy ładowaniu, maskuj w UI (
sk-***xyz) - Konfiguracja TOTP: szyfruj seed przed zapisem
- Weryfikacja TOTP: po stronie klienta (serwer nie może weryfikować OTP)
- Migracja: zaszyfruj istniejące klucze w zwykłym tekście (jednorazowo)
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.
- UI generowania klucza odzykiwania (z przerażającym ostrzeżeniem)
- Pobieranie PDF (klucz odykiwania + instrukcje)
- UI przepływu odykiwania (zapomniane hasło → podaj klucz → nowe hasło)
- Ograniczenie prób: 5 prób/godzinę
- Eksport danych: deszyfrowanie po stronie klienta → pobieranie JSON
Akceptacja: Użytkownik może odzyskać konto kluczem odykiwania.
Phase 45.5: Utwardzanie (Zgłoszenia #45–#46) — 3 dni
Cel: CSP, sanityzacja logów.
- Nagłówki CSP w Nginx (
script-src 'self', bez'unsafe-inline') - Usuń wszystkie inline event handlery (audyt:
grep -r onClick=) - Hashe SRI dla Google Fonts
- Sanityzacja logów (funkcja
sanitize()w logger.ts) - Skrypt audytu:
greplogów pod kątem emaili/kluczy API
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
Rejestracja → Logowanie → Deszyfrowanie:
- Zarejestruj z hasłem → wyloguj → zaloguj → zweryfikuj możliwość odszyfrowania starych wiadomości
Przepływ odykiwania:
- Zarejestruj → zapisz klucz odykiwania → wyloguj → zapomniane hasło → odykaj → zweryfikuj dostępność danych
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:
- Wstrzyknięcie payloadu XSS → próba eksfiltracji klucza głównego
- Dump bazy danych → weryfikacja braku treści czatu w zwykłym tekście
- Atak MITM → weryfikacja odporności zaszyfrowanych blobów na manipulację (tag GCM)
- Brute-force klucza odykiwania → weryfikacja skuteczności ograniczenia prób
- 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)
- Przepływ logowania: 100% użytkowników pomyślnie wyprowadza klucz główny
- sessionStorage: 0 wycieków kluczy w DevTools (audyt bezpieczeństwa)
- Wydajność: wyprowadzanie PBKDF2 < 500ms na medianie urządzeń
Phase 45.2 (Szyfrowanie czatu)
- Adopcja: 80% wiadomości zaszyfrowanych w 30 dni
- Wydajność: Szyfrowanie/deszyfrowanie < 10ms na wiadomość (p95)
- Audyt SQL:
SELECT content FROM chat_messages WHERE content IS NOT NULL→ 0 wierszy (po migracji)
Phase 45.3 (Sekrety)
- Klucze API: 100% przechowywanych jako zaszyfrowane blob
- TOTP: Weryfikacja po stronie klienta działa dla 100% użytkowników 2FA
- UI ustawień: 0 kluczy w zwykłym tekście widocznych w zakładce Network
Phase 45.4 (Odykiwanie)
- Wskaźnik sukcesu odykiwania: > 95% (wśród użytkowników próbujących odykać)
- Zgłoszenia do supportu: „Utraciłem klucz odykiwania" < 5% wszystkich zgłoszeń
- Pobieranie PDF: 90% użytkowników pobiera PDF z kluczem odykiwania
Phase 45.5 (Utwardzanie)
- Naruszenia CSP: 0 (konsola przeglądarki czysta)
- Audyt logów:
grep -E '(sk-ant|sk-proj|@)' /var/log/citadel/→ 0 dopasowań - Test penetracyjny: 0 krytycznych ustaleń, < 3 średnich
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:
- Iteracje: 100 000 (zalecenie OWASP 2025)
- Sól: Stała per typ wyprowadzania (
citadel-auth-v1,citadel-master-v1) - Wyjście: 256-bitowy klucz
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:
- Rozmiar klucza: 256-bit
- Rozmiar IV: 96-bit (12 bajtów, losowy per wiadomość)
- Tag auth: 128-bit (16 bajtów, dołączony do szyfrogramu)
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:
- Koszt: 12 rund (4096 iteracji)
- Sól: Losowe 128-bit per użytkownik
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).