Phase 45: Архітектура Zero-Knowledge E2EE
Arc OS — «Ми не можемо прочитати твої дані, навіть якщо б захотіли» Автор: Sentinel (Security Architect) | Дата: 2026-04-24 Статус: DONE ✅ (2026-04-28) — Issues #16-#20 завершено Затверджено: CEO
Implementation Checklist
- ✅ 45.1 — WebCrypto wrapper (
frontend/src/crm/crypto/e2ee.ts, 214 рядків) - ✅ 45.2 — Vault field encryption (
shared/vault.ts: encryptField/decryptField/isFieldEncrypted) - ✅ 45.3 — Chat message at-rest encryption (migration 015, db.ts auto-encrypt/decrypt)
- ✅ 45.4 — Recovery keys (migration 016, 4 API endpoints, RecoveryKeySection UI)
- ✅ 45.5 — CSP/security headers + PII sanitizer (
shared/pii-sanitizer.ts) - 🔲 45.6 — Розширене (multi-device sync, forward secrecy, data retention) — P2, відкладено
Проблема
Поточний стан (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)
);
Сценарії атак:
- Витік бази даних — атакувальник завантажує
citadel.db→ читає всі повідомлення, API-ключі, TOTP seeds - Компрометація сервера — атакувальник отримує SSH-доступ →
sqlite3 citadel.db .dump→ повний експорт даних - Insider threat — адмін з root-доступом → читає розмови користувачів
- Витік бекапу — незашифрований бекап БД залитий у хмару → дані відкриті
- Юридичний примус — судовий ордер змушує видати дані → усі дані користувачів читабельні
Поточний вердикт: Безпечно для 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, виведення ключів працює.
- Модуль
frontend/src/crypto/e2ee.ts - Функція
deriveAuthHash(password) - Функція
deriveMasterKey(password) - Функція
encrypt(plaintext, masterKey) - Функція
decrypt(ciphertext, iv, masterKey) - Unit-тести (Jest): round-trip шифрування
- Security audit: жодних витоків ключа в DevTools
Acceptance: Login виводить обидва ключі, master-ключ у sessionStorage.
Phase 45.2: Chat Encryption (Issue #40) — 1 тиждень
Мета: E2EE для повідомлень у чаті.
- Migration 010: колонки
content_encrypted,content_iv - Workspace.jsx: шифрування перед
handlePostMessage - Workspace.jsx: розшифрування після fetch історії
- Бекенд: зберігати blob-и, без plaintext-валідації
- Performance-тест: < 10ms на encrypt/decrypt повідомлення
Acceptance: Чат працює, сервер не може читати повідомлення (SQL-запит показує blob-и).
Phase 45.3: Secrets Encryption (Issues #41-#42) — 1 тиждень
Мета: API-ключі + TOTP seeds зашифровані.
- AccountSettings.jsx: шифрування API-ключів перед збереженням
- AccountSettings.jsx: розшифрування при завантаженні, маскування в UI (
sk-***xyz) - TOTP setup: шифрування seed перед збереженням
- TOTP-верифікація: client-side (сервер не може перевіряти OTP)
- Міграція: зашифрувати наявні plaintext-ключі (one-time)
Acceptance: Settings UI працює, сервер не читає ключі.
Phase 45.4: Key Recovery (Issues #43-#44) — 1 тиждень
Мета: Recovery-ключ + GDPR-експорт.
- UI для генерації recovery-ключа (страшне попередження)
- PDF-завантаження (recovery-ключ + інструкції)
- UI потоку відновлення (забув пароль → ввести ключ → новий пароль)
- Rate limiting: 5 спроб/годину
- Експорт даних: client-side розшифрування → JSON-завантаження
Acceptance: Користувач може відновити акаунт через recovery-ключ.
Phase 45.5: Hardening (Issues #45-#46) — 3 дні
Мета: CSP, санітизація логів.
- CSP headers у Nginx (
script-src 'self', без'unsafe-inline') - Прибрати всі inline event handlers (аудит:
grep -r onClick=) - SRI hashes для Google Fonts
- Санітизація логів (
sanitize()у logger.ts) - Аудит-скрипт:
grepлогів на email/API-ключі
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();
});
});
Інтеграційні тести
Реєстрація → Login → Розшифрування:
- Зареєструватися з паролем → вийти → увійти → перевірити, що можна розшифрувати старі повідомлення
Потік відновлення:
- Зареєструватися → зберегти recovery-ключ → вийти → forgot password → відновити → перевірити доступ до даних
Симуляція multi-device:
- Login на «device 1» (Chrome) → надіслати повідомлення
- Login на «device 2» (Firefox) → перевірити, що можна розшифрувати те саме повідомлення
Penetration Test (зовнішній, $5k бюджет)
Сценарії:
- XSS payload injection → спроба ексфільтрувати master-ключ
- Дамп бази даних → перевірити відсутність plaintext-контенту чату
- MITM-атака → перевірити, що зашифровані blob-и tamper-proof (GCM tag)
- Brute-force recovery-ключа → перевірити ефективність rate limiting
- 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 encrypted»
- ✅ «Zero-knowledge architecture»
- ✅ «Ми не можемо прочитати твої дані»
- ✅ «Навіть наші адміни не можуть розшифрувати твої повідомлення»
Не можна казати:
- ❌ «Unhackable» (нічого таким не буває)
- ❌ «Military-grade encryption» (маркетинговий BS, юридично ризиковано)
- ❌ «100% secure» (XSS, компрометація пристрою все ще можливі)
Рекомендоване:
«Твої дані зашифровані 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)
- Login flow: 100% користувачів успішно виводять master-ключ
- sessionStorage: 0 витоків ключа в DevTools (security audit)
- Продуктивність: PBKDF2-виведення < 500ms на медіанному пристрої
Phase 45.2 (Chat Encryption)
- Adoption: 80% повідомлень зашифровано протягом 30 днів
- Продуктивність: Encrypt/decrypt < 10ms на повідомлення (p95)
- SQL-аудит:
SELECT content FROM chat_messages WHERE content IS NOT NULL→ 0 рядків (після міграції)
Phase 45.3 (Secrets)
- API-ключі: 100% зберігаються як зашифровані blob-и
- TOTP: Client-side верифікація працює для 100% 2FA-користувачів
- Settings UI: 0 plaintext-ключів видно в Network tab
Phase 45.4 (Recovery)
- Recovery success rate: > 95% (з користувачів, що пробують відновлення)
- Тікети підтримки: «Втратив recovery-ключ» < 5% усіх тікетів
- Завантаження PDF: 90% користувачів завантажують recovery PDF
Phase 45.5 (Hardening)
- CSP-порушень: 0 (console у браузері чистий)
- Аудит логів:
grep -E '(sk-ant|sk-proj|@)' /var/log/citadel/→ 0 збігів - Penetration test: 0 critical findings, < 3 medium findings
Ризики та мітигація
| Ризик | Імовірність | Вплив | Мітигація |
|---|---|---|---|
| Користувач забув пароль + втратив 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 атаки на паролі.
Параметри:
- Ітерацій: 100,000 (OWASP 2025 рекомендація)
- Salt: Фіксований на тип виведення (
citadel-auth-v1,citadel-master-v1) - Вихід: 256-bit ключ
Безпека: При 100k ітерацій атакувальник може спробувати ~1000 паролів/сек на high-end GPU (vs 1M/сек для чистого SHA256).
AES-GCM
Призначення: Authenticated encryption (конфіденційність + цілісність).
Параметри:
- Розмір ключа: 256-bit
- Розмір IV: 96-bit (12 байтів, випадковий на повідомлення)
- Auth tag: 128-bit (16 байтів, додається до шифротексту)
Безпека: AES-256 quantum-resistant до ~2040 (NIST estimate). Режим GCM захищає від tampering (будь-який bit flip → розшифрування падає).
bcrypt
Призначення: Хешування пароля (server-side).
Параметри:
- Cost: 12 раундів (4096 ітерацій)
- Salt: Випадковий 128-bit на користувача
Чому подвійний хеш? Браузер надсилає bcrypt(PBKDF2(password)) → сервер зберігає bcrypt(receivedHash). Навіть якщо БД сервера витече, атакувальник має реверсити ДВА bcrypt-хеші.
Авторство: Sentinel (Security Architect). Затверджено до реалізації: CEO (2026-04-24).