Phase 45: Zero-Knowledge E2EE Architecture

Arc OS — "Мы не можем читать твои данные, даже если захотим" Автор: Sentinel (Security Architect) | Дата: 2026-04-24 Статус: DONE ✅ (2026-04-28) — задачи #16-#20 выполнены Одобрено: CEO

Чеклист реализации


Проблема

Текущее состояние (Phase 43): сервер хранит данные пользователей в открытом виде.

-- data/citadel.db (SQLite, незашифрованный файл)
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. Инсайдерская угроза — администратор с root-доступом → читает переписку пользователей
  4. Утечка бэкапа — незашифрованный бэкап БД загружается в облако → утечка
  5. Правовое принуждение — судебный ордер принуждает к передаче данных → все данные пользователей читаемы

Текущий вердикт: Безопасно для мультитенантности (пользователи не могут получить доступ к данным друг друга), НЕ безопасно для приватности пользователей (сервер может читать всё).


Видение: Zero-Knowledge Architecture

Принцип: Сервер ненадёжен. Даже с root SSH-доступом + доступом к базе данных мы не можем расшифровать данные пользователей.

Что меняется:

ДО (Phase 43):
Пользователь вводит сообщение → сервер сохраняет plaintext → администраторы могут читать ❌

ПОСЛЕ (Phase 45):
Пользователь вводит сообщение → браузер шифрует → сервер сохраняет blob → администраторы НЕ МОГУТ читать ✅

Обещание:

"Твои данные зашифрованы ключом, полученным из твоего пароля. У нас нет твоего пароля, поэтому мы не можем расшифровать твои данные — даже если захотим."

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


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

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

Пользователь → сервер → шифрует серверным ключом → сохраняет в БД
Критерий Оценка Детали
Безопасность от внешнего злоумышленника Средняя Файл БД зашифрован, но у сервера есть ключ → root-доступ = можно расшифровать
Безопасность от администратора НУЛЕВАЯ Администратор имеет серверный ключ → полный доступ
Соответствие (GDPR) Слабое "Защита данных", но не zero-knowledge
Сложность Низкая SQLCipher или PRAGMA key
Восстановление ключа Лёгкое Сервер имеет ключ → не нужны действия пользователя

Вердикт: Защищает от кражи жёсткого диска, НЕ от компрометации сервера или инсайдерской угрозы.

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

Пользователь → сервер → шифрует vault-ключом → сохраняет в БД
Сервер имеет vault-ключ (AES-256-GCM в vault.json)
Критерий Оценка Детали
Безопасность от внешнего злоумышленника Средняя Лучше чем plaintext, но vault-ключ на том же сервере
Безопасность от администратора НУЛЕВАЯ Администратор имеет vault.json → расшифрует всё
Соответствие Слабое По-прежнему не zero-knowledge
Сложность Средняя Обёртки шифрования на уровне поля
Восстановление ключа Лёгкое Управляется сервером

Вердикт: Маргинально лучше варианта A. По-прежнему не проходит тест "инсайдерская угроза".

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

Пользователь → браузер шифрует master key (НИКОГДА не отправляется на сервер) → сервер хранит blob
Сервер не может расшифровать (нет ключа)
Критерий Оценка Детали
Безопасность от внешнего злоумышленника ВЫСОКАЯ Зашифрованные данные + нет ключа = бесполезно
Безопасность от администратора ВЫСОКАЯ Администратор имеет базу данных, но не может расшифровать
Соответствие (GDPR) ОТЛИЧНОЕ Истинная минимизация данных (ст. 25)
Сложность ВЫСОКАЯ WebCrypto API, управление ключами, UX восстановления
Восстановление ключа СЛОЖНОЕ Забытый пароль = потерянные данные (требует ключа восстановления)

Вердикт: Единственный вариант, обеспечивающий zero-knowledge. Стоимость сложности оправдана ради приватности пользователей.


Обзор архитектуры

1. Двойное деривирование ключей (Split-Brain Design)

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

Решение: Получить ДВА ключа из пароля с разными солями.

Пароль пользователя: "MySecurePass123!"
      │
      ├─ PBKDF2(password, salt="citadel-auth-v1", 100k iter)
      │     ↓
      │  authBits (256-bit)
      │     ↓
      │  bcrypt(authBits, cost=12) → authHash
      │     ↓
      │  ОТПРАВИТЬ НА СЕРВЕР (для проверки при входе)
      │
      └─ PBKDF2(password, salt="citadel-master-v1", 100k iter)
            ↓
         masterKey (AES-256-GCM ключ шифрования)
            ↓
         ОСТАЁТСЯ В БРАУЗЕРЕ (sessionStorage)
         НИКОГДА не отправляется на сервер

Свойство безопасности: Компрометация сервера → злоумышленник получает authHashне может получить masterKey (другая соль = другой вывод).

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

// Auth hash (отправляется на сервер)
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
  );
  
  // Сервер снова применит bcrypt для хранения
  return Buffer.from(bits).toString("hex");
}

// Master key (остаётся в браузере)
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,  // НЕ извлекаемый (не может утечь)
    ["encrypt", "decrypt"]
  );
  
  return masterKey;
}

2. Флоу регистрации

┌─────────────────────────────────────────────────────────────┐
│ Регистрация пользователя (браузер)                          │
├─────────────────────────────────────────────────────────────┤
│  1. Пользователь вводит: email, пароль                      │
│                                                              │
│  2. Браузер получает:                                       │
│     authHash = PBKDF2(password, "auth-salt") → bcrypt      │
│     masterKey = PBKDF2(password, "master-salt") → AES key  │
│                                                              │
│  3. POST /api/auth/register                                 │
│     {                                                        │
│       email: "[email protected]",                           │
│       authHash: "2a12...bcrypt..."  ← хранится в БД        │
│     }                                                        │
│                                                              │
│  4. Сервер:                                                 │
│     - bcrypt(authHash) → password_hash (двойной хеш)       │
│     - INSERT INTO users (email, password_hash)             │
│     - Отправить письмо верификации                         │
│                                                              │
│  5. Браузер:                                                │
│     - Сохранить masterKey в sessionStorage                 │
│     - Сгенерировать ключ восстановления (шифрует masterKey)│
│     - Показать ключ восстановления: ⚠️ СОХРАНИ ЭТО!       │
│       "A83Z-KL9P-MM4X-VN2Q-8JC7"                           │
│     - Пользователь скачивает PDF                           │
│                                                              │
│  6. POST /api/crm/account/recovery-key                      │
│     {                                                        │
│       encrypted_master_key: base64(...),  ← AES-GCM blob   │
│       recovery_key_iv: base64(...)                         │
│     }                                                        │
└─────────────────────────────────────────────────────────────┘

3. Флоу входа

┌─────────────────────────────────────────────────────────────┐
│ Вход пользователя (браузер)                                 │
├─────────────────────────────────────────────────────────────┤
│  1. Пользователь вводит: email, пароль                      │
│                                                              │
│  2. Браузер получает:                                       │
│     authHash = PBKDF2(password, "auth-salt") → bcrypt      │
│     masterKey = PBKDF2(password, "master-salt") → AES key  │
│                                                              │
│  3. POST /api/auth/login                                    │
│     {                                                        │
│       email: "[email protected]",                           │
│       authHash: "2a12..."                                  │
│     }                                                        │
│                                                              │
│  4. Сервер:                                                 │
│     - Получить user.password_hash из БД                    │
│     - bcrypt.compare(authHash, password_hash)              │
│     - Если совпало: сгенерировать JWT-токен                │
│     - Вернуть { token, user_id }                           │
│                                                              │
│  5. Браузер:                                                │
│     - Сохранить JWT в localStorage (для API auth)          │
│     - Сохранить masterKey в sessionStorage (для расшифровки)│
│     - Готов шифровать/расшифровывать данные                │
└─────────────────────────────────────────────────────────────┘

4. Шифрование и отправка сообщения

┌─────────────────────────────────────────────────────────────┐
│ Отправить сообщение в чат (браузер)                        │
├─────────────────────────────────────────────────────────────┤
│  1. Пользователь вводит: "Deploy to production with sk-ant-xyz123" │
│                                                              │
│  2. Браузер шифрует:                                        │
│     plaintext = "Deploy to production with sk-ant-xyz123"  │
│     iv = crypto.getRandomValues(12 bytes)  ← случайный 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         │
│       // НЕТ поля plaintext content                        │
│     }                                                        │
│                                                              │
│  4. Сервер:                                                 │
│     - Верифицировать JWT (пользователь авторизован)        │
│     - INSERT INTO chat_messages (                          │
│         project_name,                                       │
│         worker_id,                                          │
│         role = 'user',                                      │
│         content_encrypted = BLOB,  ← непрозрачен для сервера│
│         content_iv = BLOB,                                  │
│         timestamp                                           │
│       )                                                      │
│     - Вернуть { message_id }                               │
│                                                              │
│  5. Сервер НЕ МОЖЕТ прочитать "sk-ant-xyz123" — это зашифровано │
└─────────────────────────────────────────────────────────────┘

5. Получение и расшифровка сообщения

┌─────────────────────────────────────────────────────────────┐
│ Получить историю чата (браузер)                            │
├─────────────────────────────────────────────────────────────┤
│  1. GET /api/crm/projects/arc-v2/chat/history               │
│     Authorization: Bearer <JWT>                             │
│                                                              │
│  2. Сервер:                                                 │
│     - Верифицировать JWT + владение (canAccessProject)     │
│     - SELECT content_encrypted, content_iv, timestamp      │
│       FROM chat_messages                                    │
│       WHERE project_name = 'arc-v2'                        │
│       ORDER BY timestamp DESC                               │
│       LIMIT 50                                              │
│     - Вернуть [{ content_encrypted, content_iv, ... }]     │
│                                                              │
│  3. Браузер расшифровывает КАЖДОЕ сообщение:                │
│     for (const msg of messages) {                          │
│       const ciphertext = base64Decode(msg.content_encrypted);│
│       const iv = base64Decode(msg.content_iv);             │
│       const plaintext = AES-GCM.decrypt(                   │
│         ciphertext,                                         │
│         masterKey,  ← из sessionStorage                    │
│         iv                                                  │
│       );                                                    │
│       displayMessage(plaintext);                           │
│     }                                                        │
│                                                              │
│  4. Пользователь видит: "Deploy to production with sk-ant-xyz123" │
│     Сервер видел: бессмысленный blob (8a9f3c...)           │
└─────────────────────────────────────────────────────────────┘

Изменения схемы БД

Migration 010: E2EE поля

-- Migration: Добавить зашифрованные колонки, устаревшие plaintext

-- 1. Сообщения чата
ALTER TABLE chat_messages 
  ADD COLUMN content_encrypted BLOB,
  ADD COLUMN content_iv BLOB,
  ADD COLUMN key_version INTEGER DEFAULT 1;

-- Пометить старый content как устаревший (будет перешифрован при следующем доступе)
-- НЕ удалять колонку `content` пока (обратная совместимость при migration)

-- 2. Настройки аккаунта (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. Пользователи (TOTP-seeds)
ALTER TABLE users
  ADD COLUMN totp_secret_encrypted BLOB,
  ADD COLUMN totp_secret_iv BLOB;

-- 4. Ключи восстановления
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,  -- опциональная подсказка (первые 4 символа: "A83Z-****-****")
  created_at TEXT NOT NULL,
  last_used_at TEXT,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

-- 5. Лог ротации ключей шифрования
CREATE TABLE key_versions (
  version INTEGER PRIMARY KEY,
  created_at TEXT NOT NULL,
  deprecated_at TEXT,
  notes TEXT  -- напр., "Annual rotation", "Security incident"
);

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

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

Генерация ключа восстановления

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

Решение: Ключ восстановления (генерируется один раз, хранится пользователем оффлайн).

// Генерация ключа восстановления (браузер)
async function generateRecoveryKey(masterKey: CryptoKey): Promise<string> {
  // 1. Генерация случайного 128-битного ключа
  const recoveryBytes = crypto.getRandomValues(new Uint8Array(16));
  
  // 2. Кодировать как читаемую строку (Base32, без неоднозначных символов)
  // Результат: "A83Z-KL9P-MM4X-VN2Q-8JC7" (5 групп по 4 символа)
  const recoveryKey = base32Encode(recoveryBytes, { groups: 5 });
  
  // 3. Получить AES-ключ из 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,  // меньше итераций (recovery и так случаен)
      hash: "SHA-256"
    },
    recoveryKeyMaterial,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt"]
  );
  
  // 4. Экспортировать master key (для шифрования)
  const masterKeyExport = await crypto.subtle.exportKey("raw", masterKey);
  
  // 5. Зашифровать master key ключом восстановления
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encryptedMasterKey = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    recoveryAESKey,
    masterKeyExport
  );
  
  // 6. Отправить на сервер для хранения
  await fetch('/api/crm/account/recovery-key', {
    method: 'POST',
    body: JSON.stringify({
      encrypted_master_key: base64Encode(encryptedMasterKey),
      recovery_key_iv: base64Encode(iv)
    })
  });
  
  // 7. Вернуть ключ восстановления пользователю (ПОКАЗАТЬ ТОЛЬКО ОДИН РАЗ)
  return recoveryKey;  // "A83Z-KL9P-MM4X-VN2Q-8JC7"
}

Флоу восстановления (забытый пароль):

┌─────────────────────────────────────────────────────────────┐
│ Восстановление пароля                                       │
├─────────────────────────────────────────────────────────────┤
│  1. Пользователь нажимает "Забыл пароль"                    │
│                                                              │
│  2. Браузер показывает:                                     │
│     "Введи ключ восстановления:"                            │
│     [____-____-____-____-____]                             │
│                                                              │
│  3. Пользователь вводит: A83Z-KL9P-MM4X-VN2Q-8JC7          │
│                                                              │
│  4. Получить зашифрованный master key с сервера:           │
│     GET /api/crm/account/[email protected]     │
│     → { encrypted_master_key, recovery_key_iv }            │
│                                                              │
│  5. Расшифровать master key:                                │
│     recoveryAESKey = deriveKey(recoveryKey)                │
│     masterKey = AES-GCM.decrypt(                           │
│       encrypted_master_key,                                 │
│       recoveryAESKey,                                       │
│       recovery_key_iv                                       │
│     )                                                        │
│                                                              │
│  6. Запросить НОВЫЙ пароль:                                 │
│     "Задай новый пароль:"                                   │
│     [________] (должен отличаться от старого)              │
│                                                              │
│  7. Получить новый authHash из нового пароля               │
│                                                              │
│  8. Обновить сервер:                                        │
│     PUT /api/auth/reset-password                            │
│     { recovery_key, new_auth_hash }                        │
│                                                              │
│  9. Сервер обновляет password_hash                          │
│                                                              │
│ 10. Браузер сохраняет masterKey в sessionStorage            │
│     → Пользователь снова имеет доступ к зашифрованным данным│
└─────────────────────────────────────────────────────────────┘

Rate limiting: макс. 5 попыток восстановления в час (защита от брутфорса).


Свойства безопасности

От чего мы защищаем

Угроза Меры Уровень
Утечка БД (кража .db-файла) ✅ Данные зашифрованы, ключей нет Полная
Компрометация сервера (root SSH) ✅ Нет master-ключей на сервере Полная
Инсайдерская угроза (злоупотребление администратора) ✅ Администратор не может расшифровать Полная
MITM атака (перехват сети) ✅ HTTPS + зашифрованный payload Полная
Утечка бэкапа (S3, Drive) ✅ Бэкапы содержат только blob'ы Полная
Правовое принуждение (судебный ордер) ✅ Не можем расшифровать без пароля пользователя Полная
XSS-атака (кража master key) ✅ CSP headers + нет inline JS Частичная
Брутфорс пароля ✅ bcrypt + PBKDF2 100k итераций Сильная

От чего мы НЕ защищаем (вне скоупа)

Угроза Ответственность пользователя
Кража физического устройства (разблокированный ноутбук) Блокировка экрана, полное шифрование диска
Вредоносное браузерное расширение Проверяй расширения, используй надёжные
Кейлоггер на устройстве Антивирус, безопасное устройство
Социальная инженерия (фишинг ключа восстановления) Обучение безопасности
Квантовые вычисления (взлом AES-256) Нецелесообразно до ~2040 (план NIST)

Дорожная карта реализации

Phase 45.1: Основа (Issues #39) — 2 недели

Цель: WebCrypto обёртка, деривирование ключей работает.

Приёмка: Вход получает оба ключа, master key в sessionStorage.

Phase 45.2: Шифрование чата (Issue #40) — 1 неделя

Цель: E2EE сообщений чата.

Приёмка: Чат работает, сервер не может читать сообщения (SQL-запрос показывает blob'ы).

Phase 45.3: Шифрование секретов (Issues #41-#42) — 1 неделя

Цель: API-ключи + TOTP-seeds зашифрованы.

Приёмка: UI настроек работает, сервер не может читать ключи.

Phase 45.4: Восстановление ключей (Issues #43-#44) — 1 неделя

Цель: Ключ восстановления + GDPR-экспорт.

Приёмка: Пользователь может восстановить аккаунт с ключом восстановления.

Phase 45.5: Усиление (Issues #45-#46) — 3 дня

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

Приёмка: CSP violations = 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;  // перевернуть один бит
    await expect(decrypt(ciphertext, iv, key)).rejects.toThrow();
  });
});

Интеграционные тесты

  1. Registration → Login → Decrypt:

    • Регистрация с паролем → выход → вход → проверить, что можно расшифровать старые сообщения
  2. Флоу восстановления:

    • Регистрация → сохранить ключ восстановления → выход → забытый пароль → восстановление → проверить доступ к данным
  3. Симуляция мульти-девайс:

    • Вход на "устройство 1" (Chrome) → отправить сообщение
    • Вход на "устройство 2" (Firefox) → проверить, что можно расшифровать то же сообщение

Пентест (внешний, бюджет $5k)

Сценарии:

  1. Инъекция XSS payload → попытка экфильтрации master key
  2. Дамп БД → проверить отсутствие plaintext в содержимом чата
  3. MITM атака → проверить tamper-proof blob'ы (тег GCM)
  4. Брутфорс ключа восстановления → проверить эффективность rate limiting
  5. Атака по timing-side-channel → проверить constant-time в PBKDF2

Сроки: После завершения #39-#42 (Q3 2026).


Стратегия миграции

Обратная совместимость

Проблема: У существующих пользователей данные в plaintext. Нельзя сломать их доступ.

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

Шаг 1: Dual-Write (Phase 45.2)

// Бэкенд: писать В ОБОИХ форматах — plaintext и зашифрованном
async function handlePostMessage(req) {
  const { content, content_encrypted, content_iv } = req.body;
  
  db.run(`
    INSERT INTO chat_messages (
      content,             -- старый plaintext (устаревший)
      content_encrypted,   -- новый E2EE blob
      content_iv
    ) VALUES (?, ?, ?)
  `, [
    content || null,  // null для E2EE клиента
    content_encrypted,
    content_iv
  ]);
}

// Фронтенд: отправлять В ОБОИХ форматах (во время перехода)
const { ciphertext, iv } = await encrypt(plaintext, masterKey);
await fetch('/api/chat', {
  body: JSON.stringify({
    content: plaintext,        // для старого API
    content_encrypted: ciphertext,  // для нового E2EE
    content_iv: iv
  })
});

Шаг 2: Read Preference (Phase 45.2)

// Бэкенд: возвращать зашифрованное если есть, иначе plaintext
const messages = db.query(`SELECT * FROM chat_messages`).all();
for (const msg of messages) {
  if (msg.content_encrypted) {
    // E2EE сообщение (предпочтительно)
    yield { content_encrypted: msg.content_encrypted, content_iv: msg.content_iv };
  } else {
    // Legacy plaintext (устаревший, предупредить пользователя)
    yield { content: msg.content, legacy: true };
  }
}

// Фронтенд: расшифровать если E2EE, иначе показать plaintext с предупреждением
if (msg.content_encrypted) {
  const plaintext = await decrypt(msg.content_encrypted, msg.content_iv, masterKey);
  displayMessage(plaintext);
} else {
  displayMessage(msg.content);
  showWarning('Это сообщение было отправлено до включения E2EE. Зашифровать его?');
}

Шаг 3: Инструмент перешифрования (Phase 45.3)

// Настройки → Безопасность → "Зашифровать старые сообщения"
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('Все сообщения зашифрованы!');
}

// Бэкенд: обновить сообщение, СТЕРЕТЬ plaintext
app.put('/api/chat/messages/:id/encrypt', (req) => {
  db.run(`
    UPDATE chat_messages
    SET content_encrypted = ?,
        content_iv = ?,
        content = NULL  ← СТЕРЕТЬ plaintext
    WHERE id = ?
  `, [req.content_encrypted, req.content_iv, req.params.id]);
});

Шаг 4: Удаление колонки plaintext (Phase 46)

-- Через 90 дней проверить: 0 plaintext сообщений
SELECT COUNT(*) FROM chat_messages WHERE content IS NOT NULL;
-- Если 0, удалить колонку

ALTER TABLE chat_messages DROP COLUMN content;

UX-соображения

Пугающие, но честные предупреждения

Генерация ключа восстановления:

┌────────────────────────────────────────────────────────────┐
│  ⚠️ КРИТИЧНО: Сохрани ключ восстановления                  │
├────────────────────────────────────────────────────────────┤
│  Твои данные зашифрованы ключом, который есть только у тебя.│
│  Если ты потеряешь пароль И этот ключ восстановления, твои │
│  данные будут УТЕРЯНЫ НАВСЕГДА. Мы НЕ МОЖЕМ их восстановить.│
│                                                             │
│  Ключ восстановления:                                      │
│  ┌──────────────────────────────────────────────────────┐ │
│  │  A83Z-KL9P-MM4X-VN2Q-8JC7                            │ │
│  └──────────────────────────────────────────────────────┘ │
│                                                             │
│  [ Скачать PDF ]  [ Печать ]  [ Копировать ]               │
│                                                             │
│  ☐ Я сохранил этот ключ в безопасном месте                │
│                                                             │
│  [ Продолжить ]  ← неактивна до установки флажка          │
└────────────────────────────────────────────────────────────┘

Первый вход после включения E2EE:

┌────────────────────────────────────────────────────────────┐
│  🔒 Сквозное шифрование включено                           │
├────────────────────────────────────────────────────────────┤
│  Твои сообщения, API-ключи и секреты теперь шифруются      │
│  на твоём устройстве перед отправкой на наши серверы.      │
│                                                             │
│  ✓ Мы НЕ МОЖЕМ читать твои данные (даже если захотим)     │
│  ✓ Утечка БД = злоумышленник получает бесполезные blob'ы  │
│  ✗ Забытый пароль + потерянный ключ = потеря данных        │
│                                                             │
│  [ Подробнее ]  [ Понял ]                                  │
└────────────────────────────────────────────────────────────┘

Обратная связь о производительности

// Показывать индикатор шифрования при вводе (ненавязчиво)
<textarea 
  placeholder="Введи сообщение... 🔒 (зашифровано)"
  onChange={handleChange}
/>

// Показывать спиннер "Шифрование..." перед отправкой (< 10мс, почти незаметно)
<button onClick={handleSend}>
  {encrypting ? '🔐 Шифрование...' : 'Отправить'}
</button>

Влияние на соответствие требованиям

Статьи GDPR

Статья До (Phase 43) После (Phase 45) Улучшение
Ст. 25 (Защита данных by design) Plaintext хранение E2EE по умолчанию ✅ Соответствует
Ст. 32 (Безопасность обработки) JWT + bcrypt + AES-256 E2EE ✅ Усилено
Ст. 17 (Право на удаление) Удаление из БД + зашифровано (уже бесполезно) ✅ Сильнее
Ст. 20 (Переносимость данных) Экспорт сервером Клиентская расшифровка + экспорт ✅ Под контролем пользователя

Маркетинговые заявления (требует юридической проверки)

Можно говорить:

Нельзя говорить:

Рекомендуемая формулировка:

"Твои данные зашифрованы сквозным шифрованием по стандарту AES-256-GCM. Мы используем zero-knowledge архитектуру: шифрование происходит на твоём устройстве, и у нас никогда нет доступа к твоим ключам расшифровки. Даже наши администраторы не могут прочитать твои зашифрованные сообщения, API-ключи или секреты."


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

Затраты

Позиция Усилия Риск
Реализация WebCrypto 2 недели разработки Средний (криптография сложна)
UX управления ключами 1 неделя дизайна + разработки Высокий (путаница пользователей)
Механизм восстановления 1 неделя разработки Высокий (неправильный UX = потеря данных)
Миграция (dual-write) 3 дня разработки Низкий (обратная совместимость)
Пентест $5 000
Нагрузка на поддержку +20% обращений Средний ("Я потерял ключ восстановления")

Итого: ~6 недель разработки + $5k внешний аудит.

Выгоды

Выгода Влияние
Доверие пользователей Высокое — "приватность данных" — главная забота предприятий
Соответствие нормативам Соответствие GDPR ст. 25 (обязательно для клиентов из ЕС)
Защита от утечки Даже катастрофическая утечка → зашифрованные blob'ы (минимальный ущерб)
Конкурентное преимущество Немногие AI-платформы предлагают E2EE (Notion, ClickUp, Monday = plaintext)
Корпоративные продажи Обязательно для healthcare, finance, legal секторов (HIPAA, SOC 2)

Вердикт: Выгоды перевешивают затраты. E2EE — обязательный минимум для корпоративных AI-платформ.


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

Phase 45.1 (Основа)

Phase 45.2 (Шифрование чата)

Phase 45.3 (Секреты)

Phase 45.4 (Восстановление)

Phase 45.5 (Усиление)


Риски и меры снижения

Риск Вероятность Влияние Меры снижения
Пользователь забывает пароль + теряет ключ → потеря данных Средняя КРИТИЧНО 1. Пугающее предупреждение при настройке
2. Авто-отправка PDF на email
3. Опц.: печать QR-кода ключа восстановления
Баг WebCrypto → повреждённые данные Низкая ВЫСОКОЕ 1. Обширные unit-тесты
2. Dual-write при выкатке (сохранять plaintext-бэкап)
3. Внешний аудит безопасности
Деградация производительности (PBKDF2 медленно на старых устройствах) Средняя Среднее 1. Адаптивные итерации (определять скорость устройства)
2. Web Worker для неблокирующего деривирования
Несовместимость браузеров (баги Safari) Низкая Среднее 1. Тестировать на Safari 15+, Chrome, Firefox
2. Fallback: polyfill для старых браузеров (или блокировать их)
Нагрузка на поддержку ("Не могу получить доступ к данным") Высокая Среднее 1. Подробная документация
2. UI мастера восстановления
3. Проактивный email: "Ты сохранил ключ восстановления?"

Приложение: Криптографические примитивы

PBKDF2-SHA256

Назначение: Замедлить брутфорс-атаки на пароли.

Параметры:

Безопасность: При 100k итерациях злоумышленник может проверить ~1 000 паролей/сек на высокопроизводительном GPU (против 1М/сек для plain SHA256).

AES-GCM

Назначение: Аутентифицированное шифрование (конфиденциальность + целостность).

Параметры:

Безопасность: AES-256 устойчив к квантовым вычислениям до ~2040 (оценка NIST). Режим GCM предотвращает модификацию (любой flip бита → расшифровка не удаётся).

bcrypt

Назначение: Хэширование паролей (серверная сторона).

Параметры:

Почему двойное хэширование? Браузер отправляет bcrypt(PBKDF2(password)) → сервер хранит bcrypt(receivedHash). Даже если БД сервера утечёт, злоумышленнику нужно обратить ДВА bcrypt-хэша.


Автор: Sentinel (Security Architect). Одобрено к реализации: CEO (2026-04-24).