Phase 45: Zero-Knowledge E2EE Architecture
Arc OS — "Мы не можем читать твои данные, даже если захотим" Автор: Sentinel (Security Architect) | Дата: 2026-04-24 Статус: DONE ✅ (2026-04-28) — задачи #16-#20 выполнены Одобрено: CEO
Чеклист реализации
- ✅ 45.1 — WebCrypto обёртка (
frontend/src/crm/crypto/e2ee.ts, 214 строк) - ✅ 45.2 — Шифрование полей vault (
shared/vault.ts: encryptField/decryptField/isFieldEncrypted) - ✅ 45.3 — Шифрование сообщений чата at-rest (migration 015, db.ts авто-шифрование/расшифровка)
- ✅ 45.4 — Ключи восстановления (migration 016, 4 API-эндпоинта, UI RecoveryKeySection)
- ✅ 45.5 — CSP/security headers + PII sanitizer (
shared/pii-sanitizer.ts) - 🔲 45.6 — Расширенные (мульти-девайс синхронизация, forward secrecy, хранение данных) — P2 отложено
Проблема
Текущее состояние (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)
);
Сценарии атак:
- Утечка базы данных — злоумышленник скачивает
citadel.db→ читает все сообщения, API-ключи, TOTP-seeds - Компрометация сервера — злоумышленник получает SSH-доступ →
sqlite3 citadel.db .dump→ полный экспорт данных - Инсайдерская угроза — администратор с root-доступом → читает переписку пользователей
- Утечка бэкапа — незашифрованный бэкап БД загружается в облако → утечка
- Правовое принуждение — судебный ордер принуждает к передаче данных → все данные пользователей читаемы
Текущий вердикт: Безопасно для мультитенантности (пользователи не могут получить доступ к данным друг друга), НЕ безопасно для приватности пользователей (сервер может читать всё).
Видение: 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 обёртка, деривирование ключей работает.
- Модуль
frontend/src/crypto/e2ee.ts - Функция
deriveAuthHash(password) - Функция
deriveMasterKey(password) - Функция
encrypt(plaintext, masterKey) - Функция
decrypt(ciphertext, iv, masterKey) - Unit-тесты (Jest): round-trip шифрование
- Аудит безопасности: нет утечки ключей в DevTools
Приёмка: Вход получает оба ключа, master key в sessionStorage.
Phase 45.2: Шифрование чата (Issue #40) — 1 неделя
Цель: E2EE сообщений чата.
- Migration 010: колонки
content_encrypted,content_iv - Workspace.jsx: шифрование перед
handlePostMessage - Workspace.jsx: расшифровка после получения истории
- Бэкенд: хранить blob'ы, без валидации plaintext
- Тест производительности: < 10мс на шифрование/расшифровку сообщения
Приёмка: Чат работает, сервер не может читать сообщения (SQL-запрос показывает blob'ы).
Phase 45.3: Шифрование секретов (Issues #41-#42) — 1 неделя
Цель: API-ключи + TOTP-seeds зашифрованы.
- AccountSettings.jsx: шифровать API-ключи перед сохранением
- AccountSettings.jsx: расшифровывать при загрузке, маскировать в UI (
sk-***xyz) - Настройка TOTP: шифровать seed перед сохранением
- Верификация TOTP: клиентская сторона (сервер не может проверить OTP)
- Migration: зашифровать существующие plaintext-ключи (однократно)
Приёмка: UI настроек работает, сервер не может читать ключи.
Phase 45.4: Восстановление ключей (Issues #43-#44) — 1 неделя
Цель: Ключ восстановления + GDPR-экспорт.
- UI генерации ключа восстановления (с пугающим предупреждением)
- Скачивание PDF (ключ восстановления + инструкции)
- UI флоу восстановления (забытый пароль → ввод ключа → новый пароль)
- Rate limiting: 5 попыток/час
- Экспорт данных: клиентская расшифровка → скачивание JSON
Приёмка: Пользователь может восстановить аккаунт с ключом восстановления.
Phase 45.5: Усиление (Issues #45-#46) — 3 дня
Цель: CSP, санитизация логов.
- CSP headers в Nginx (
script-src 'self', без'unsafe-inline') - Удалить все inline event handlers (аудит:
grep -r onClick=) - SRI-хеши для Google Fonts
- Санитизация логов (функция
sanitize()в logger.ts) - Скрипт аудита:
grepлогов на email'ы/API-ключи
Приёмка: 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();
});
});
Интеграционные тесты
Registration → Login → Decrypt:
- Регистрация с паролем → выход → вход → проверить, что можно расшифровать старые сообщения
Флоу восстановления:
- Регистрация → сохранить ключ восстановления → выход → забытый пароль → восстановление → проверить доступ к данным
Симуляция мульти-девайс:
- Вход на "устройство 1" (Chrome) → отправить сообщение
- Вход на "устройство 2" (Firefox) → проверить, что можно расшифровать то же сообщение
Пентест (внешний, бюджет $5k)
Сценарии:
- Инъекция XSS payload → попытка экфильтрации master key
- Дамп БД → проверить отсутствие plaintext в содержимом чата
- MITM атака → проверить tamper-proof blob'ы (тег GCM)
- Брутфорс ключа восстановления → проверить эффективность rate limiting
- Атака по 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 (Переносимость данных) | Экспорт сервером | Клиентская расшифровка + экспорт | ✅ Под контролем пользователя |
Маркетинговые заявления (требует юридической проверки)
Можно говорить:
- ✅ "Сквозное шифрование"
- ✅ "Zero-knowledge архитектура"
- ✅ "Мы не можем читать твои данные"
- ✅ "Даже наши администраторы не могут расшифровать твои сообщения"
Нельзя говорить:
- ❌ "Невзламываемо" (ничего нет)
- ❌ "Военный уровень шифрования" (маркетинговая чушь, юридически рискованно)
- ❌ "100% безопасно" (XSS, компрометация устройства по-прежнему возможны)
Рекомендуемая формулировка:
"Твои данные зашифрованы сквозным шифрованием по стандарту 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 (Основа)
- Флоу входа: 100% пользователей успешно получают master key
- sessionStorage: 0 утечек ключей в DevTools (аудит безопасности)
- Производительность: деривирование PBKDF2 < 500мс на медианном устройстве
Phase 45.2 (Шифрование чата)
- Адоптация: 80% сообщений зашифровано в течение 30 дней
- Производительность: шифрование/расшифровка < 10мс на сообщение (p95)
- Аудит SQL:
SELECT content FROM chat_messages WHERE content IS NOT NULL→ 0 строк (после миграции)
Phase 45.3 (Секреты)
- API-ключи: 100% хранятся как зашифрованные blob'ы
- TOTP: клиентская верификация работает для 100% пользователей с 2FA
- UI настроек: 0 plaintext ключей видно во вкладке Network
Phase 45.4 (Восстановление)
- Успешность восстановления: > 95% (пользователей, пытающихся восстановить доступ)
- Обращения в поддержку: "Потерял ключ восстановления" < 5% всех обращений
- Скачивания PDF: 90% пользователей скачивают PDF с ключом восстановления
Phase 45.5 (Усиление)
- CSP violations: 0 (консоль браузера чиста)
- Аудит логов:
grep -E '(sk-ant|sk-proj|@)' /var/log/citadel/→ 0 совпадений - Пентест: 0 критических находок, < 3 средних находок
Риски и меры снижения
| Риск | Вероятность | Влияние | Меры снижения |
|---|---|---|---|
| Пользователь забывает пароль + теряет ключ → потеря данных | Средняя | КРИТИЧНО | 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
Назначение: Замедлить брутфорс-атаки на пароли.
Параметры:
- Итерации: 100 000 (рекомендация OWASP 2025)
- Соль: фиксированная для каждого типа деривирования (
citadel-auth-v1,citadel-master-v1) - Вывод: 256-битный ключ
Безопасность: При 100k итерациях злоумышленник может проверить ~1 000 паролей/сек на высокопроизводительном GPU (против 1М/сек для plain SHA256).
AES-GCM
Назначение: Аутентифицированное шифрование (конфиденциальность + целостность).
Параметры:
- Размер ключа: 256 бит
- Размер IV: 96 бит (12 байт, случайный для каждого сообщения)
- Auth tag: 128 бит (16 байт, добавляется к шифртексту)
Безопасность: AES-256 устойчив к квантовым вычислениям до ~2040 (оценка NIST). Режим GCM предотвращает модификацию (любой flip бита → расшифровка не удаётся).
bcrypt
Назначение: Хэширование паролей (серверная сторона).
Параметры:
- Cost: 12 раундов (4 096 итераций)
- Соль: случайные 128 бит на пользователя
Почему двойное хэширование? Браузер отправляет bcrypt(PBKDF2(password)) → сервер хранит bcrypt(receivedHash). Даже если БД сервера утечёт, злоумышленнику нужно обратить ДВА bcrypt-хэша.
Автор: Sentinel (Security Architect). Одобрено к реализации: CEO (2026-04-24).