Phase 45: Архітектура Zero-Knowledge E2EE

Arc OS — «Ми не можемо прочитати твої дані, навіть якщо б захотіли» Автор: Sentinel (Security Architect) | Дата: 2026-04-24 Статус: DONE ✅ (2026-04-28) — Issues #16-#20 завершено Затверджено: CEO

Implementation Checklist


Проблема

Поточний стан (Phase 43): Сервер зберігає дані користувачів у відкритому вигляді.

-- data/citadel.db (SQLite, unencrypted file)
CREATE TABLE chat_messages (
  content TEXT NOT NULL  -- ❌ "sk-ant-abc123xyz (my 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)
);

Сценарії атак:

  1. Витік бази даних — атакувальник завантажує citadel.db → читає всі повідомлення, API-ключі, TOTP seeds
  2. Компрометація сервера — атакувальник отримує SSH-доступ → sqlite3 citadel.db .dump → повний експорт даних
  3. Insider threat — адмін з root-доступом → читає розмови користувачів
  4. Витік бекапу — незашифрований бекап БД залитий у хмару → дані відкриті
  5. Юридичний примус — судовий ордер змушує видати дані → усі дані користувачів читабельні

Поточний вердикт: Безпечно для multi-tenancy (користувачі не мають доступу до даних одне одного), але НЕ безпечно для приватності користувача (сервер може читати все).


Бачення: Zero-Knowledge архітектура

Принцип: Сервер є untrusted. Навіть з root SSH-доступом + доступом до бази даних ми не можемо розшифрувати дані користувача.

Що змінюється:

BEFORE (Phase 43):
User types message → server stores plaintext → admins can read it ❌

AFTER (Phase 45):
User types message → browser encrypts → server stores blob → admins CANNOT read it ✅

Обіцянка:

«Твої дані зашифровані ключем, виведеним з твого пароля. Ми не маємо твого пароля, тож не можемо розшифрувати твої дані — навіть якщо б захотіли.»

Модель: Signal Protocol (спрощено), ProtonMail, 1Password.


Аналіз рішень: моделі шифрування

Варіант A: Server-Side Encryption (At-Rest Only)

User → server → encrypt with server key → store in DB
Критерій Оцінка Деталі
Безпека проти зовнішнього зловмисника Середня Файл БД зашифрований, але сервер має ключ → root-доступ = можна розшифрувати
Безпека проти адміна НУЛЬОВА Адмін має серверний ключ → повний доступ
Compliance (GDPR) Слабка «Data protection», але не zero-knowledge
Складність Низька SQLCipher або PRAGMA key
Відновлення ключа Просте Сервер має ключ → жодних дій від користувача

Вердикт: Захищає від викраденого жорсткого диска, але НЕ від компрометації сервера чи insider threat.

Варіант B: Application-Layer Encryption (Server-Side)

User → server → encrypt with vault key → store in DB
Server has vault key (AES-256-GCM in vault.json)
Критерій Оцінка Деталі
Безпека проти зовнішнього зловмисника Середня Краще за plaintext, але vault-ключ на тому ж сервері
Безпека проти адміна НУЛЬОВА Адмін має vault.json → розшифровує все
Compliance Слабка Все ще не zero-knowledge
Складність Середня Field-level encryption wrappers
Відновлення ключа Просте Керується сервером

Вердикт: Маргінально краще за Варіант A. Все ще не проходить тест на «insider threat».

Варіант C: End-to-End Encryption (Zero-Knowledge) — ОБРАНО

User → browser encrypts with master key (NEVER sent to server) → server stores blob
Server cannot decrypt (no key)
Критерій Оцінка Деталі
Безпека проти зовнішнього зловмисника ВИСОКА Зашифровані дані + без ключа = непридатні
Безпека проти адміна ВИСОКА Адмін має БД, але не може розшифрувати
Compliance (GDPR) ВІДМІННА Справжня мінімізація даних (Art. 25)
Складність ВИСОКА WebCrypto API, key management, recovery UX
Відновлення ключа СКЛАДНЕ Користувач забув пароль = дані втрачено (потрібен recovery-ключ)

Вердикт: Єдиний варіант, що дає zero-knowledge. Складність варта приватності користувача.


Огляд архітектури

1. Two-Key Derivation (Split-Brain Design)

Проблема: Пароль надсилається на сервер для авторизації. Як вивести ключ шифрування, не надсилаючи пароль?

Рішення: Виводимо ДВА ключі з пароля з різними salt.

User Password: "MySecurePass123!"
      │
      ├─ PBKDF2(password, salt="citadel-auth-v1", 100k iter)
      │     ↓
      │  authBits (256-bit)
      │     ↓
      │  bcrypt(authBits, cost=12) → authHash
      │     ↓
      │  SEND TO SERVER (for login verification)
      │
      └─ PBKDF2(password, salt="citadel-master-v1", 100k iter)
            ↓
         masterKey (AES-256-GCM encryption key)
            ↓
         STAYS IN BROWSER (sessionStorage)
         NEVER sent to server

Властивість безпеки: Компрометація сервера → атакувальник отримує authHashне може вивести masterKey (інший salt = інший результат).

Код (frontend/src/crypto/e2ee.ts):

// Auth hash (sent to server)
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 will bcrypt this again for storage
  return Buffer.from(bits).toString("hex");
}

// Master key (kept in browser)
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,  // NOT extractable (cannot leak)
    ["encrypt", "decrypt"]
  );
  
  return masterKey;
}

2. Потік реєстрації

┌─────────────────────────────────────────────────────────────┐
│ User Registration (browser)                                 │
├─────────────────────────────────────────────────────────────┤
│  1. User enters: email, password                            │
│                                                              │
│  2. Browser derives:                                        │
│     authHash = PBKDF2(password, "auth-salt") → bcrypt      │
│     masterKey = PBKDF2(password, "master-salt") → AES key  │
│                                                              │
│  3. POST /api/auth/register                                 │
│     {                                                        │
│       email: "[email protected]",                           │
│       authHash: "2a12...bcrypt..."  ← stored in DB         │
│     }                                                        │
│                                                              │
│  4. Server:                                                 │
│     - bcrypt(authHash) → password_hash (double hash)       │
│     - INSERT INTO users (email, password_hash)             │
│     - Send verification email                               │
│                                                              │
│  5. Browser:                                                │
│     - Store masterKey in sessionStorage                    │
│     - Generate recovery key (encrypt masterKey)            │
│     - Show recovery key to user: ⚠️ SAVE THIS!            │
│       "A83Z-KL9P-MM4X-VN2Q-8JC7"                           │
│     - User downloads PDF                                   │
│                                                              │
│  6. POST /api/crm/account/recovery-key                      │
│     {                                                        │
│       encrypted_master_key: base64(...),  ← AES-GCM blob   │
│       recovery_key_iv: base64(...)                         │
│     }                                                        │
└─────────────────────────────────────────────────────────────┘

3. Потік входу

┌─────────────────────────────────────────────────────────────┐
│ User Login (browser)                                        │
├─────────────────────────────────────────────────────────────┤
│  1. User enters: email, password                            │
│                                                              │
│  2. Browser derives:                                        │
│     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:                                                 │
│     - Fetch user.password_hash from DB                     │
│     - bcrypt.compare(authHash, password_hash)              │
│     - If match: generate JWT token                         │
│     - Return { token, user_id }                            │
│                                                              │
│  5. Browser:                                                │
│     - Store JWT in localStorage (for API auth)             │
│     - Store masterKey in sessionStorage (for decryption)   │
│     - Ready to encrypt/decrypt data                        │
└─────────────────────────────────────────────────────────────┘

4. Зашифрувати та надіслати повідомлення

┌─────────────────────────────────────────────────────────────┐
│ Send Chat Message (browser)                                │
├─────────────────────────────────────────────────────────────┤
│  1. User types: "Deploy to production with sk-ant-xyz123"  │
│                                                              │
│  2. Browser encrypts:                                       │
│     plaintext = "Deploy to production with sk-ant-xyz123"  │
│     iv = crypto.getRandomValues(12 bytes)  ← random 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         │
│       // NO plaintext content field                        │
│     }                                                        │
│                                                              │
│  4. Server:                                                 │
│     - Verify JWT (user authorized)                         │
│     - INSERT INTO chat_messages (                          │
│         project_name,                                       │
│         worker_id,                                          │
│         role = 'user',                                      │
│         content_encrypted = BLOB,  ← opaque to server      │
│         content_iv = BLOB,                                  │
│         timestamp                                           │
│       )                                                      │
│     - Return { message_id }                                │
│                                                              │
│  5. Server CANNOT read "sk-ant-xyz123" — it's encrypted   │
└─────────────────────────────────────────────────────────────┘

5. Отримати та розшифрувати повідомлення

┌─────────────────────────────────────────────────────────────┐
│ Fetch Chat History (browser)                               │
├─────────────────────────────────────────────────────────────┤
│  1. GET /api/crm/projects/arc-v2/chat/history               │
│     Authorization: Bearer <JWT>                             │
│                                                              │
│  2. Server:                                                 │
│     - Verify JWT + ownership (canAccessProject)            │
│     - SELECT content_encrypted, content_iv, timestamp      │
│       FROM chat_messages                                    │
│       WHERE project_name = 'arc-v2'                        │
│       ORDER BY timestamp DESC                               │
│       LIMIT 50                                              │
│     - Return [{ content_encrypted, content_iv, ... }]      │
│                                                              │
│  3. Browser decrypts EACH message:                          │
│     for (const msg of messages) {                          │
│       const ciphertext = base64Decode(msg.content_encrypted);│
│       const iv = base64Decode(msg.content_iv);             │
│       const plaintext = AES-GCM.decrypt(                   │
│         ciphertext,                                         │
│         masterKey,  ← from sessionStorage                  │
│         iv                                                  │
│       );                                                    │
│       displayMessage(plaintext);                           │
│     }                                                        │
│                                                              │
│  4. User sees: "Deploy to production with sk-ant-xyz123"   │
│     Server saw: meaningless blob (8a9f3c...)               │
└─────────────────────────────────────────────────────────────┘

Зміни в схемі бази даних

Migration 010: E2EE-поля

-- Migration: Add encrypted columns, deprecate plaintext

-- 1. Chat messages
ALTER TABLE chat_messages 
  ADD COLUMN content_encrypted BLOB,
  ADD COLUMN content_iv BLOB,
  ADD COLUMN key_version INTEGER DEFAULT 1;

-- Mark old content as deprecated (will be re-encrypted on next access)
-- DO NOT drop `content` column yet (backwards compat during migration)

-- 2. Account settings (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. Users (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,  -- optional hint (first 4 chars: "A83Z-****-****")
  created_at TEXT NOT NULL,
  last_used_at TEXT,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

-- 5. Encryption key rotation log
CREATE TABLE key_versions (
  version INTEGER PRIMARY KEY,
  created_at TEXT NOT NULL,
  deprecated_at TEXT,
  notes TEXT  -- e.g., "Annual rotation", "Security incident"
);

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

Управління ключами

Генерація recovery-ключа

Проблема: Користувач забув пароль → masterKey втрачено → усі дані не відновити.

Рішення: Recovery-ключ (генерується один раз, зберігається користувачем офлайн).

// Generate recovery key (browser)
async function generateRecoveryKey(masterKey: CryptoKey): Promise<string> {
  // 1. Generate random 128-bit key
  const recoveryBytes = crypto.getRandomValues(new Uint8Array(16));
  
  // 2. Encode as human-readable string (Base32, no ambiguous chars)
  // Result: "A83Z-KL9P-MM4X-VN2Q-8JC7" (5 groups of 4 chars)
  const recoveryKey = base32Encode(recoveryBytes, { groups: 5 });
  
  // 3. Derive AES key from recovery bytes
  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,  // lower iterations (recovery is already random)
      hash: "SHA-256"
    },
    recoveryKeyMaterial,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt"]
  );
  
  // 4. Export master key (for encryption)
  const masterKeyExport = await crypto.subtle.exportKey("raw", masterKey);
  
  // 5. Encrypt master key with recovery key
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encryptedMasterKey = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    recoveryAESKey,
    masterKeyExport
  );
  
  // 6. Send to server for storage
  await fetch('/api/crm/account/recovery-key', {
    method: 'POST',
    body: JSON.stringify({
      encrypted_master_key: base64Encode(encryptedMasterKey),
      recovery_key_iv: base64Encode(iv)
    })
  });
  
  // 7. Return recovery key to user (SHOW ONLY ONCE)
  return recoveryKey;  // "A83Z-KL9P-MM4X-VN2Q-8JC7"
}

Потік відновлення (забув пароль):

┌─────────────────────────────────────────────────────────────┐
│ Password Recovery                                           │
├─────────────────────────────────────────────────────────────┤
│  1. User clicks "Forgot Password"                           │
│                                                              │
│  2. Browser shows:                                          │
│     "Enter your recovery key:"                              │
│     [____-____-____-____-____]                             │
│                                                              │
│  3. User enters: A83Z-KL9P-MM4X-VN2Q-8JC7                  │
│                                                              │
│  4. Fetch encrypted master key from server:                │
│     GET /api/crm/account/[email protected]     │
│     → { encrypted_master_key, recovery_key_iv }            │
│                                                              │
│  5. Decrypt master key:                                     │
│     recoveryAESKey = deriveKey(recoveryKey)                │
│     masterKey = AES-GCM.decrypt(                           │
│       encrypted_master_key,                                 │
│       recoveryAESKey,                                       │
│       recovery_key_iv                                       │
│     )                                                        │
│                                                              │
│  6. Prompt for NEW password:                                │
│     "Set a new password:"                                   │
│     [________] (must be different from old)                │
│                                                              │
│  7. Derive new authHash from new password                   │
│                                                              │
│  8. Update server:                                          │
│     PUT /api/auth/reset-password                            │
│     { recovery_key, new_auth_hash }                        │
│                                                              │
│  9. Server updates password_hash                            │
│                                                              │
│ 10. Browser stores masterKey in sessionStorage              │
│     → User regains access to encrypted data                │
└─────────────────────────────────────────────────────────────┘

Rate limiting: Максимум 5 спроб відновлення на годину (захист від brute-force).


Властивості безпеки

Від чого ми захищаємо

Загроза Мітигація Рівень
Витік бази даних (вкрадений файл .db) ✅ Дані зашифровані, ключів нема Повний
Компрометація сервера (root SSH) ✅ На сервері немає master-ключів Повний
Insider threat (зловживання адміна) ✅ Адмін не може розшифрувати Повний
MITM-атака (перехоплення мережі) ✅ HTTPS + зашифрований payload Повний
Витік бекапу (S3, Drive) ✅ Бекапи містять лише blob-и Повний
Юридичний примус (судовий ордер) ✅ Неможливо розшифрувати без пароля користувача Повний
XSS-атака (викрасти master-ключ) ✅ CSP headers + жодного inline JS Частковий
Brute-force пароля ✅ bcrypt + PBKDF2 100k ітерацій Сильний

Від чого ми НЕ захищаємо (поза скоупом)

Загроза Відповідальність користувача
Фізичне викрадення пристрою (незаблокований ноутбук) Блокування екрана, full-disk encryption
Шкідливе розширення браузера Перевіряти розширення, ставити перевірені
Keylogger на пристрої Антивірус, безпечний пристрій
Соціальна інженерія (фішинг recovery-ключа) Тренінги з безпеки
Квантові обчислення (зламати AES-256) Не реалістично до ~2040 (NIST timeline)

Implementation Roadmap

Phase 45.1: Foundation (Issues #39) — 2 тижні

Мета: WebCrypto wrapper, виведення ключів працює.

Acceptance: Login виводить обидва ключі, master-ключ у sessionStorage.

Phase 45.2: Chat Encryption (Issue #40) — 1 тиждень

Мета: E2EE для повідомлень у чаті.

Acceptance: Чат працює, сервер не може читати повідомлення (SQL-запит показує blob-и).

Phase 45.3: Secrets Encryption (Issues #41-#42) — 1 тиждень

Мета: API-ключі + TOTP seeds зашифровані.

Acceptance: Settings UI працює, сервер не читає ключі.

Phase 45.4: Key Recovery (Issues #43-#44) — 1 тиждень

Мета: Recovery-ключ + GDPR-експорт.

Acceptance: Користувач може відновити акаунт через recovery-ключ.

Phase 45.5: Hardening (Issues #45-#46) — 3 дні

Мета: CSP, санітизація логів.

Acceptance: CSP-порушень = 0, логи чисті.


Стратегія тестування

Unit-тести (Jest)

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

  test('deriveMasterKey is deterministic', 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('wrong key cannot decrypt', 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('tampered ciphertext fails (GCM auth tag)', async () => {
    const key = await deriveMasterKey('test');
    const { ciphertext, iv } = await encrypt('Secret', key);
    ciphertext[0] ^= 0xFF;  // flip one bit
    await expect(decrypt(ciphertext, iv, key)).rejects.toThrow();
  });
});

Інтеграційні тести

  1. Реєстрація → Login → Розшифрування:

    • Зареєструватися з паролем → вийти → увійти → перевірити, що можна розшифрувати старі повідомлення
  2. Потік відновлення:

    • Зареєструватися → зберегти recovery-ключ → вийти → forgot password → відновити → перевірити доступ до даних
  3. Симуляція multi-device:

    • Login на «device 1» (Chrome) → надіслати повідомлення
    • Login на «device 2» (Firefox) → перевірити, що можна розшифрувати те саме повідомлення

Penetration Test (зовнішній, $5k бюджет)

Сценарії:

  1. XSS payload injection → спроба ексфільтрувати master-ключ
  2. Дамп бази даних → перевірити відсутність plaintext-контенту чату
  3. MITM-атака → перевірити, що зашифровані blob-и tamper-proof (GCM tag)
  4. Brute-force recovery-ключа → перевірити ефективність rate limiting
  5. Side-channel timing attack → перевірити, що PBKDF2 constant-time

Timeline: Після завершення #39-#42 (Q3 2026).


Стратегія міграції

Backwards Compatibility

Проблема: Існуючі користувачі мають plaintext-дані. Не можна зламати їм доступ.

Рішення: Поступова міграція.

Крок 1: Dual-Write (Phase 45.2)

// Backend: write BOTH plaintext and encrypted
async function handlePostMessage(req) {
  const { content, content_encrypted, content_iv } = req.body;
  
  db.run(`
    INSERT INTO chat_messages (
      content,             -- old plaintext (deprecated)
      content_encrypted,   -- new E2EE blob
      content_iv
    ) VALUES (?, ?, ?)
  `, [
    content || null,  // null if E2EE client
    content_encrypted,
    content_iv
  ]);
}

// Frontend: send BOTH (during transition)
const { ciphertext, iv } = await encrypt(plaintext, masterKey);
await fetch('/api/chat', {
  body: JSON.stringify({
    content: plaintext,        // for old API
    content_encrypted: ciphertext,  // for new E2EE
    content_iv: iv
  })
});

Крок 2: Read Preference (Phase 45.2)

// Backend: return encrypted if available, else plaintext
const messages = db.query(`SELECT * FROM chat_messages`).all();
for (const msg of messages) {
  if (msg.content_encrypted) {
    // E2EE message (preferred)
    yield { content_encrypted: msg.content_encrypted, content_iv: msg.content_iv };
  } else {
    // Legacy plaintext (deprecated, warn user)
    yield { content: msg.content, legacy: true };
  }
}

// Frontend: decrypt if E2EE, else show plaintext with warning
if (msg.content_encrypted) {
  const plaintext = await decrypt(msg.content_encrypted, msg.content_iv, masterKey);
  displayMessage(plaintext);
} else {
  displayMessage(msg.content);
  showWarning('This message was sent before E2EE was enabled. Re-encrypt it?');
}

Крок 3: Re-Encryption Tool (Phase 45.3)

// Settings → Security → "Encrypt Old Messages"
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('All messages encrypted!');
}

// Backend: update message, WIPE plaintext
app.put('/api/chat/messages/:id/encrypt', (req) => {
  db.run(`
    UPDATE chat_messages
    SET content_encrypted = ?,
        content_iv = ?,
        content = NULL  ← WIPE plaintext
    WHERE id = ?
  `, [req.content_encrypted, req.content_iv, req.params.id]);
});

Крок 4: Drop Plaintext Column (Phase 46)

-- After 90 days, verify 0 plaintext messages
SELECT COUNT(*) FROM chat_messages WHERE content IS NOT NULL;
-- If 0, drop column

ALTER TABLE chat_messages DROP COLUMN content;

UX-міркування

Страшні, але чесні попередження

Генерація recovery-ключа:

┌────────────────────────────────────────────────────────────┐
│  ⚠️ CRITICAL: Save Your Recovery Key                      │
├────────────────────────────────────────────────────────────┤
│  Your data is encrypted with a key only YOU have. If you  │
│  lose your password AND this recovery key, your data is   │
│  PERMANENTLY LOST. We CANNOT recover it for you.          │
│                                                             │
│  Recovery Key:                                             │
│  ┌──────────────────────────────────────────────────────┐ │
│  │  A83Z-KL9P-MM4X-VN2Q-8JC7                            │ │
│  └──────────────────────────────────────────────────────┘ │
│                                                             │
│  [ Download PDF ]  [ Print ]  [ Copy ]                     │
│                                                             │
│  ☐ I have saved this recovery key in a safe place         │
│                                                             │
│  [ Continue ]  ← disabled until checkbox checked           │
└────────────────────────────────────────────────────────────┘

Перший вхід після увімкнення E2EE:

┌────────────────────────────────────────────────────────────┐
│  🔒 End-to-End Encryption Enabled                          │
├────────────────────────────────────────────────────────────┤
│  Your messages, API keys, and secrets are now encrypted    │
│  on your device before being sent to our servers.          │
│                                                             │
│  ✓ We CANNOT read your data (even if we wanted to)        │
│  ✓ Database breach = attacker gets useless blobs          │
│  ✗ Forgot password + lost recovery key = data lost        │
│                                                             │
│  [ Learn More ]  [ Got It ]                                │
└────────────────────────────────────────────────────────────┘

Performance Feedback

// Show encryption indicator while typing (subtle)
<textarea 
  placeholder="Type a message... 🔒 (encrypted)"
  onChange={handleChange}
/>

// Show "Encrypting..." spinner before send (< 10ms, barely visible)
<button onClick={handleSend}>
  {encrypting ? '🔐 Encrypting...' : 'Send'}
</button>

Вплив на Compliance

GDPR Articles

Стаття До (Phase 43) Після (Phase 45) Покращення
Art. 25 (Data protection by design) Plaintext storage E2EE за замовчуванням ✅ Compliant
Art. 32 (Security of processing) JWT + bcrypt + AES-256 E2EE ✅ Посилено
Art. 17 (Right to erasure) Delete from DB + зашифровано (вже непридатно) ✅ Сильніше
Art. 20 (Data portability) Server export Client-side розшифрування + експорт ✅ Керує користувач

Маркетингові заяви (потрібен legal review)

Можна казати:

Не можна казати:

Рекомендоване:

«Твої дані зашифровані end-to-end за допомогою індустріального стандарту AES-256-GCM. Ми використовуємо zero-knowledge архітектуру: шифрування відбувається на твоєму пристрої, і ми ніколи не маємо доступу до твоїх ключів розшифрування. Навіть наші адміністратори не можуть прочитати твої зашифровані повідомлення, API-ключі чи секрети.»


Аналіз вигод і витрат

Витрати

Стаття Зусилля Ризик
Реалізація WebCrypto 2 тижні розробки Середній (крипта складна)
UX управління ключами 1 тиждень design + dev Високий (плутанина користувача)
Механізм відновлення 1 тиждень розробки Високий (поганий UX = втрата даних)
Міграція (dual-write) 3 дні розробки Низький (backwards compat)
Penetration testing $5,000
Навантаження на саппорт +20% тікетів Середній («Я загубив recovery key»)

Усього: ~6 тижнів розробки + $5k зовнішнього аудиту.

Вигоди

Вигода Вплив
Довіра користувачів Висока — «приватність даних» = головна турбота enterprise
Регуляторний compliance Дотримання GDPR Art. 25 (обов’язково для EU-клієнтів)
Захист від витоків Навіть катастрофічний витік → зашифровані blob-и (мінімальна шкода)
Конкурентна перевага Мало AI-платформ дають E2EE (Notion, ClickUp, Monday = plaintext)
Enterprise-продажі Обов’язково для healthcare, finance, legal (HIPAA, SOC 2)

Вердикт: Вигоди переважають витрати. E2EE — table-stakes для enterprise AI-платформ.


Метрики успіху

Phase 45.1 (Foundation)

Phase 45.2 (Chat Encryption)

Phase 45.3 (Secrets)

Phase 45.4 (Recovery)

Phase 45.5 (Hardening)


Ризики та мітигація

Ризик Імовірність Вплив Мітигація
Користувач забув пароль + втратив recovery-ключ → дані втрачено Середня КРИТИЧНИЙ 1. Страшне попередження під час setup
2. Автоматичний email з recovery PDF
3. Опційно: друк QR-коду recovery-ключа
Баг у WebCrypto → пошкоджені дані Низька ВИСОКИЙ 1. Масштабні unit-тести
2. Dual-write під час rollout (зберігати plaintext-бекап)
3. Зовнішній security-аудит
Деградація продуктивності (PBKDF2 повільний на старих пристроях) Середня Середній 1. Адаптивні ітерації (детектити швидкість пристрою)
2. Web Worker для non-blocking виведення
Несумісність браузерів (Safari-баги) Низька Середній 1. Тести на Safari 15+, Chrome, Firefox
2. Fallback: polyfill для старих браузерів (або блокувати їх)
Навантаження на саппорт («Я не можу зайти у свої дані») Висока Середній 1. Вичерпна документація
2. UI-візард відновлення
3. Проактивний email: «Ти зберіг свій recovery-ключ?»

Додаток: криптографічні примітиви

PBKDF2-SHA256

Призначення: Сповільнити brute-force атаки на паролі.

Параметри:

Безпека: При 100k ітерацій атакувальник може спробувати ~1000 паролів/сек на high-end GPU (vs 1M/сек для чистого SHA256).

AES-GCM

Призначення: Authenticated encryption (конфіденційність + цілісність).

Параметри:

Безпека: AES-256 quantum-resistant до ~2040 (NIST estimate). Режим GCM захищає від tampering (будь-який bit flip → розшифрування падає).

bcrypt

Призначення: Хешування пароля (server-side).

Параметри:

Чому подвійний хеш? Браузер надсилає bcrypt(PBKDF2(password)) → сервер зберігає bcrypt(receivedHash). Навіть якщо БД сервера витече, атакувальник має реверсити ДВА bcrypt-хеші.


Авторство: Sentinel (Security Architect). Затверджено до реалізації: CEO (2026-04-24).