Phase 45: Arquitetura Zero-Knowledge E2EE
Arc OS — "Não conseguimos ler seus dados, mesmo que quiséssemos" Autor: Sentinel (Arquiteto de Segurança) | Data: 2026-04-24 Status: DONE ✅ (2026-04-28) — issues #16-#20 concluídas Aprovado por: CEO
Checklist de Implementação
- ✅ 45.1 — Wrapper WebCrypto (
frontend/src/crm/crypto/e2ee.ts, 214 linhas) - ✅ 45.2 — Criptografia de campos no vault (
shared/vault.ts: encryptField/decryptField/isFieldEncrypted) - ✅ 45.3 — Criptografia de mensagens de chat em repouso (migration 015, db.ts auto-encrypt/decrypt)
- ✅ 45.4 — Recovery keys (migration 016, 4 endpoints de API, UI RecoveryKeySection)
- ✅ 45.5 — Headers CSP/segurança + sanitizador PII (
shared/pii-sanitizer.ts) - 🔲 45.6 — Avançado (sync multi-dispositivo, forward secrecy, retenção de dados) — P2 adiado
O Problema
Estado atual (Phase 43): O servidor armazena dados do usuário em texto plano.
-- data/citadel.db (SQLite, arquivo não criptografado)
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)
);
Cenários de ataque:
- Vazamento de banco de dados — atacante baixa
citadel.db→ lê todas as mensagens, chaves de API, seeds TOTP - Comprometimento do servidor — atacante obtém acesso SSH →
sqlite3 citadel.db .dump→ exportação completa dos dados - Ameaça interna — admin com acesso root → lê conversas dos usuários
- Vazamento de backup — backup não criptografado enviado para a nuvem → exposto
- Compulsão legal — ordem judicial força entrega dos dados → todos os dados legíveis
Veredicto atual: Seguro para multi-tenancy (usuários não acessam dados uns dos outros), NÃO seguro para privacidade do usuário (servidor pode ler tudo).
A Visão: Arquitetura Zero-Knowledge
Princípio: O servidor é não confiável. Mesmo com acesso root SSH + acesso ao banco de dados, não conseguimos descriptografar os dados do usuário.
O que muda:
ANTES (Phase 43):
Usuário digita mensagem → servidor armazena em texto plano → admins podem ler ❌
DEPOIS (Phase 45):
Usuário digita mensagem → browser criptografa → servidor armazena blob → admins NÃO conseguem ler ✅
A promessa:
"Seus dados são criptografados com uma chave derivada da sua senha. Não temos sua senha, então não conseguimos descriptografar seus dados — mesmo que quiséssemos."
Modelo: Signal Protocol (simplificado), ProtonMail, 1Password.
Análise de Decisão: Modelos de Criptografia
Opção A: Criptografia Server-Side (Apenas em Repouso)
Usuário → servidor → criptografa com chave do servidor → armazena no DB
| Critério | Avaliação | Detalhes |
|---|---|---|
| Segurança contra externos | Média | Arquivo de DB criptografado, mas servidor tem a chave → acesso root = pode descriptografar |
| Segurança contra admin | ZERO | Admin tem a chave do servidor → acesso total |
| Conformidade (GDPR) | Fraca | "Proteção de dados" mas não é zero-knowledge |
| Complexidade | Baixa | SQLCipher ou PRAGMA key |
| Recuperação de chave | Fácil | Servidor tem a chave → nenhuma ação do usuário necessária |
Veredicto: Protege contra HD roubado, NÃO contra comprometimento do servidor ou ameaça interna.
Opção B: Criptografia na Camada de Aplicação (Server-Side)
Usuário → servidor → criptografa com chave do vault → armazena no DB
Servidor tem a chave do vault (AES-256-GCM em vault.json)
| Critério | Avaliação | Detalhes |
|---|---|---|
| Segurança contra externos | Média | Melhor que texto plano, mas chave do vault no mesmo servidor |
| Segurança contra admin | ZERO | Admin tem vault.json → descriptografa tudo |
| Conformidade | Fraca | Ainda não é zero-knowledge |
| Complexidade | Média | Wrappers de criptografia por campo |
| Recuperação de chave | Fácil | Gerenciada pelo servidor |
Veredicto: Marginalmente melhor que a Opção A. Ainda falha no teste de "ameaça interna".
Opção C: Criptografia End-to-End (Zero-Knowledge) — ESCOLHIDA
Usuário → browser criptografa com master key (NUNCA enviada ao servidor) → servidor armazena blob
Servidor não consegue descriptografar (sem a chave)
| Critério | Avaliação | Detalhes |
|---|---|---|
| Segurança contra externos | ALTA | Dados criptografados + sem chave = inútil |
| Segurança contra admin | ALTA | Admin tem o banco de dados mas não consegue descriptografar |
| Conformidade (GDPR) | EXCELENTE | Verdadeira minimização de dados (Art. 25) |
| Complexidade | ALTA | WebCrypto API, gerenciamento de chaves, UX de recuperação |
| Recuperação de chave | DIFÍCIL | Usuário perde senha = dados perdidos (exige recovery key) |
Veredicto: Única opção que entrega zero-knowledge. Custo de complexidade vale pela privacidade do usuário.
Visão Geral da Arquitetura
1. Derivação de Duas Chaves (Design Split-Brain)
Problema: Senha enviada ao servidor para auth. Como derivar chave de criptografia sem enviar a senha?
Solução: Derivar DUAS chaves da senha com salts diferentes.
Senha do Usuário: "MySecurePass123!"
│
├─ PBKDF2(password, salt="citadel-auth-v1", 100k iter)
│ ↓
│ authBits (256-bit)
│ ↓
│ bcrypt(authBits, cost=12) → authHash
│ ↓
│ ENVIA AO SERVIDOR (para verificação de login)
│
└─ PBKDF2(password, salt="citadel-master-v1", 100k iter)
↓
masterKey (chave de criptografia AES-256-GCM)
↓
FICA NO BROWSER (sessionStorage)
NUNCA enviada ao servidor
Propriedade de segurança: Comprometimento do servidor → atacante obtém authHash → não consegue derivar masterKey (salt diferente = saída diferente).
Código (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. Fluxo de Registro
┌─────────────────────────────────────────────────────────────┐
│ Registro do Usuário (browser) │
├─────────────────────────────────────────────────────────────┤
│ 1. Usuário informa: email, senha │
│ │
│ 2. Browser deriva: │
│ authHash = PBKDF2(password, "auth-salt") → bcrypt │
│ masterKey = PBKDF2(password, "master-salt") → AES key │
│ │
│ 3. POST /api/auth/register │
│ { │
│ email: "[email protected]", │
│ authHash: "2a12...bcrypt..." ← armazenado no DB │
│ } │
│ │
│ 4. Servidor: │
│ - bcrypt(authHash) → password_hash (hash duplo) │
│ - INSERT INTO users (email, password_hash) │
│ - Envia email de verificação │
│ │
│ 5. Browser: │
│ - Armazena masterKey em sessionStorage │
│ - Gera recovery key (criptografa masterKey) │
│ - Exibe recovery key ao usuário: ⚠️ SALVE ISTO! │
│ "A83Z-KL9P-MM4X-VN2Q-8JC7" │
│ - Usuário baixa PDF │
│ │
│ 6. POST /api/crm/account/recovery-key │
│ { │
│ encrypted_master_key: base64(...), ← blob AES-GCM │
│ recovery_key_iv: base64(...) │
│ } │
└─────────────────────────────────────────────────────────────┘
3. Fluxo de Login
┌─────────────────────────────────────────────────────────────┐
│ Login do Usuário (browser) │
├─────────────────────────────────────────────────────────────┤
│ 1. Usuário informa: email, senha │
│ │
│ 2. Browser deriva: │
│ authHash = PBKDF2(password, "auth-salt") → bcrypt │
│ masterKey = PBKDF2(password, "master-salt") → AES key │
│ │
│ 3. POST /api/auth/login │
│ { │
│ email: "[email protected]", │
│ authHash: "2a12..." │
│ } │
│ │
│ 4. Servidor: │
│ - Busca user.password_hash do DB │
│ - bcrypt.compare(authHash, password_hash) │
│ - Se coincidir: gera token JWT │
│ - Retorna { token, user_id } │
│ │
│ 5. Browser: │
│ - Armazena JWT em localStorage (para auth de API) │
│ - Armazena masterKey em sessionStorage (para decrypt) │
│ - Pronto para criptografar/descriptografar dados │
└─────────────────────────────────────────────────────────────┘
4. Criptografar e Enviar Mensagem
┌─────────────────────────────────────────────────────────────┐
│ Enviar Mensagem de Chat (browser) │
├─────────────────────────────────────────────────────────────┤
│ 1. Usuário digita: "Deploy to production with sk-ant-xyz123"│
│ │
│ 2. Browser criptografa: │
│ plaintext = "Deploy to production with sk-ant-xyz123" │
│ iv = crypto.getRandomValues(12 bytes) ← nonce aleatório│
│ ciphertext = AES-GCM.encrypt(plaintext, masterKey, iv) │
│ │
│ 3. POST /api/crm/projects/arc-v2/chat │
│ { │
│ worker_id: "developer", │
│ content_encrypted: "8a9f3c...", ← blob base64 │
│ content_iv: "7b2e1a..." ← IV base64 │
│ // SEM campo content em texto plano │
│ } │
│ │
│ 4. Servidor: │
│ - Verifica JWT (usuário autorizado) │
│ - INSERT INTO chat_messages ( │
│ project_name, │
│ worker_id, │
│ role = 'user', │
│ content_encrypted = BLOB, ← opaco para o servidor │
│ content_iv = BLOB, │
│ timestamp │
│ ) │
│ - Retorna { message_id } │
│ │
│ 5. Servidor NÃO consegue ler "sk-ant-xyz123" — criptografado│
└─────────────────────────────────────────────────────────────┘
5. Receber e Descriptografar Mensagem
┌─────────────────────────────────────────────────────────────┐
│ Buscar Histórico de Chat (browser) │
├─────────────────────────────────────────────────────────────┤
│ 1. GET /api/crm/projects/arc-v2/chat/history │
│ Authorization: Bearer <JWT> │
│ │
│ 2. Servidor: │
│ - Verifica JWT + ownership (canAccessProject) │
│ - SELECT content_encrypted, content_iv, timestamp │
│ FROM chat_messages │
│ WHERE project_name = 'arc-v2' │
│ ORDER BY timestamp DESC │
│ LIMIT 50 │
│ - Retorna [{ content_encrypted, content_iv, ... }] │
│ │
│ 3. Browser descriptografa CADA mensagem: │
│ for (const msg of messages) { │
│ const ciphertext = base64Decode(msg.content_encrypted);│
│ const iv = base64Decode(msg.content_iv); │
│ const plaintext = AES-GCM.decrypt( │
│ ciphertext, │
│ masterKey, ← do sessionStorage │
│ iv │
│ ); │
│ displayMessage(plaintext); │
│ } │
│ │
│ 4. Usuário vê: "Deploy to production with sk-ant-xyz123" │
│ Servidor viu: blob sem sentido (8a9f3c...) │
└─────────────────────────────────────────────────────────────┘
Mudanças no Schema do Banco de Dados
Migration 010: Campos 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'));
Gerenciamento de Chaves
Geração de Recovery Key
Problema: Usuário esquece a senha → masterKey perdida → todos os dados irrecuperáveis.
Solução: Recovery key (gerada uma vez, armazenada offline pelo usuário).
// 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"
}
Fluxo de recuperação (esqueceu a senha):
┌─────────────────────────────────────────────────────────────┐
│ Recuperação de Senha │
├─────────────────────────────────────────────────────────────┤
│ 1. Usuário clica em "Esqueci a Senha" │
│ │
│ 2. Browser exibe: │
│ "Informe sua recovery key:" │
│ [____-____-____-____-____] │
│ │
│ 3. Usuário informa: A83Z-KL9P-MM4X-VN2Q-8JC7 │
│ │
│ 4. Busca master key criptografada no servidor: │
│ GET /api/crm/account/[email protected] │
│ → { encrypted_master_key, recovery_key_iv } │
│ │
│ 5. Descriptografa master key: │
│ recoveryAESKey = deriveKey(recoveryKey) │
│ masterKey = AES-GCM.decrypt( │
│ encrypted_master_key, │
│ recoveryAESKey, │
│ recovery_key_iv │
│ ) │
│ │
│ 6. Solicita NOVA senha: │
│ "Defina uma nova senha:" │
│ [________] (deve ser diferente da antiga) │
│ │
│ 7. Deriva novo authHash da nova senha │
│ │
│ 8. Atualiza no servidor: │
│ PUT /api/auth/reset-password │
│ { recovery_key, new_auth_hash } │
│ │
│ 9. Servidor atualiza password_hash │
│ │
│ 10. Browser armazena masterKey em sessionStorage │
│ → Usuário recupera acesso aos dados criptografados │
└─────────────────────────────────────────────────────────────┘
Rate limiting: Máx. 5 tentativas de recuperação por hora (previne força bruta).
Propriedades de Segurança
Contra o que Nos Protegemos
| Ameaça | Mitigação | Nível |
|---|---|---|
Vazamento de banco de dados (arquivo .db roubado) |
✅ Dados criptografados, sem chaves | Completo |
| Comprometimento do servidor (root SSH) | ✅ Sem master keys no servidor | Completo |
| Ameaça interna (abuso de admin) | ✅ Admin não consegue descriptografar | Completo |
| Ataque MITM (interceptação de rede) | ✅ HTTPS + payload criptografado | Completo |
| Vazamento de backup (S3, Drive) | ✅ Backups contêm apenas blobs | Completo |
| Compulsão legal (ordem judicial) | ✅ Não é possível descriptografar sem a senha do usuário | Completo |
| Ataque XSS (roubo de master key) | ✅ Headers CSP + sem JS inline | Parcial |
| Força bruta de senha | ✅ bcrypt + PBKDF2 100k iterações | Forte |
Contra o que NÃO Nos Protegemos (Fora do Escopo)
| Ameaça | Responsabilidade do Usuário |
|---|---|
| Roubo de dispositivo físico (laptop desbloqueado) | Bloqueio de tela, criptografia de disco completo |
| Extensão de browser maliciosa | Revisar extensões, usar apenas confiáveis |
| Keylogger no dispositivo | Antivírus, dispositivo seguro |
| Engenharia social (phishing da recovery key) | Treinamento de conscientização de segurança |
| Computação quântica (quebra do AES-256) | Inviável até ~2040 (estimativa NIST) |
Roadmap de Implementação
Phase 45.1: Fundação (issues #39) — 2 semanas
Objetivo: Wrapper WebCrypto, derivação de chave funcionando.
- Módulo
frontend/src/crypto/e2ee.ts - Função
deriveAuthHash(password) - Função
deriveMasterKey(password) - Função
encrypt(plaintext, masterKey) - Função
decrypt(ciphertext, iv, masterKey) - Testes unitários (Jest): criptografia round-trip
- Auditoria de segurança: sem vazamento de chave no DevTools
Aceite: Login deriva ambas as chaves, master key em sessionStorage.
Phase 45.2: Criptografia de Chat (issue #40) — 1 semana
Objetivo: Mensagens de chat com E2EE.
- Migration 010: colunas
content_encrypted,content_iv - Workspace.jsx: criptografa antes de
handlePostMessage - Workspace.jsx: descriptografa após buscar histórico
- Backend: armazena blobs, sem validação de texto plano
- Teste de performance: < 10ms por mensagem para criptografar/descriptografar
Aceite: Chat funciona, servidor não consegue ler mensagens (query SQL mostra blobs).
Phase 45.3: Criptografia de Secrets (issues #41-#42) — 1 semana
Objetivo: Chaves de API + seeds TOTP criptografados.
- AccountSettings.jsx: criptografa chaves de API antes de salvar
- AccountSettings.jsx: descriptografa ao carregar, mascara na UI (
sk-***xyz) - Setup do TOTP: criptografa seed antes de salvar
- Verificação do TOTP: client-side (servidor não consegue verificar OTP)
- Migration: criptografa chaves em texto plano existentes (uma vez)
Aceite: UI de configurações funciona, servidor não consegue ler chaves.
Phase 45.4: Recuperação de Chave (issues #43-#44) — 1 semana
Objetivo: Recovery key + exportação GDPR.
- UI de geração de recovery key (aviso assustador)
- Download de PDF (recovery key + instruções)
- UI do fluxo de recuperação (esqueci a senha → insere chave → nova senha)
- Rate limiting: 5 tentativas/hora
- Exportação de dados: descriptografia client-side → download JSON
Aceite: Usuário consegue recuperar a conta com a recovery key.
Phase 45.5: Hardening (issues #45-#46) — 3 dias
Objetivo: CSP, sanitização de logs.
- Headers CSP no Nginx (
script-src 'self', sem'unsafe-inline') - Remover todos os event handlers inline (auditoria:
grep -r onClick=) - Hashes SRI para Google Fonts
- Sanitização de log (função
sanitize()em logger.ts) - Script de auditoria:
grepem logs para emails/chaves de API
Aceite: Violações CSP = 0, logs limpos.
Estratégia de Testes
Testes Unitários (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();
});
});
Testes de Integração
Registro → Login → Descriptografar:
- Registra com senha → sai → faz login → verifica se consegue descriptografar mensagens antigas
Fluxo de recuperação:
- Registra → salva recovery key → sai → esquece senha → recupera → verifica dados acessíveis
Simulação multi-dispositivo:
- Login no "dispositivo 1" (Chrome) → envia mensagem
- Login no "dispositivo 2" (Firefox) → verifica se consegue descriptografar a mesma mensagem
Teste de Penetração (Externo, orçamento $5k)
Cenários:
- Injeção de payload XSS → tentativa de exfiltrar master key
- Dump do banco de dados → verificar ausência de conteúdo de chat em texto plano
- Ataque MITM → verificar blobs criptografados à prova de adulteração (tag GCM)
- Força bruta de recovery key → verificar eficácia do rate limiting
- Ataque de timing de canal lateral → verificar tempo constante do PBKDF2
Cronograma: Após a conclusão das issues #39-#42 (Q3 2026).
Estratégia de Migração
Compatibilidade com Versões Anteriores
Problema: Usuários existentes têm dados em texto plano. Não dá pra quebrar o acesso deles.
Solução: Migração gradual.
Passo 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
})
});
Passo 2: Preferência de Leitura (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?');
}
Passo 3: Ferramenta de Re-criptografia (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 ← APAGA texto plano
WHERE id = ?
`, [req.content_encrypted, req.content_iv, req.params.id]);
});
Passo 4: Remover Coluna Plaintext (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;
Considerações de UX
Avisos Assustadores mas Honestos
Geração de recovery key:
┌────────────────────────────────────────────────────────────┐
│ ⚠️ CRÍTICO: Salve Sua Recovery Key │
├────────────────────────────────────────────────────────────┤
│ Seus dados são criptografados com uma chave que apenas │
│ VOCÊ tem. Se você perder a senha E esta recovery key, │
│ seus dados serão PERMANENTEMENTE PERDIDOS. NÃO conseguimos│
│ recuperá-los para você. │
│ │
│ Recovery Key: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ A83Z-KL9P-MM4X-VN2Q-8JC7 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ [ Baixar PDF ] [ Imprimir ] [ Copiar ] │
│ │
│ ☐ Salvei esta recovery key em um lugar seguro │
│ │
│ [ Continuar ] ← desabilitado até o checkbox ser marcado │
└────────────────────────────────────────────────────────────┘
Primeiro login após o E2EE ser ativado:
┌────────────────────────────────────────────────────────────┐
│ 🔒 Criptografia End-to-End Ativada │
├────────────────────────────────────────────────────────────┤
│ Suas mensagens, chaves de API e secrets agora são │
│ criptografados no seu dispositivo antes de serem enviados │
│ aos nossos servidores. │
│ │
│ ✓ NÃO conseguimos ler seus dados (mesmo que quiséssemos) │
│ ✓ Vazamento de banco de dados = atacante obtém blobs inúteis│
│ ✗ Esqueceu a senha + perdeu a recovery key = dados perdidos│
│ │
│ [ Saiba Mais ] [ Entendido ] │
└────────────────────────────────────────────────────────────┘
Feedback de Performance
// 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>
Impacto de Conformidade
Artigos do GDPR
| Artigo | Antes (Phase 43) | Depois (Phase 45) | Melhoria |
|---|---|---|---|
| Art. 25 (Proteção de dados por design) | Armazenamento em texto plano | E2EE por padrão | ✅ Conforme |
| Art. 32 (Segurança do processamento) | JWT + bcrypt | + AES-256 E2EE | ✅ Aprimorado |
| Art. 17 (Direito ao apagamento) | Excluir do DB | + criptografado (já inutilizável) | ✅ Mais forte |
| Art. 20 (Portabilidade de dados) | Exportação pelo servidor | Descriptografia client-side + exportação | ✅ Controlado pelo usuário |
Afirmações de Marketing (Revisão Jurídica Necessária)
Pode dizer:
- ✅ "Criptografia end-to-end"
- ✅ "Arquitetura zero-knowledge"
- ✅ "Não conseguimos ler seus dados"
- ✅ "Nem nossos admins conseguem descriptografar suas mensagens"
Não pode dizer:
- ❌ "Impossível de hackear" (nada é)
- ❌ "Criptografia militar" (marketing enganoso, risco legal)
- ❌ "100% seguro" (XSS, comprometimento de dispositivo ainda são possíveis)
Recomendado:
"Seus dados são criptografados end-to-end usando AES-256-GCM, padrão da indústria. Usamos uma arquitetura zero-knowledge: a criptografia acontece no seu dispositivo e nunca temos acesso às suas chaves de descriptografia. Nem os nossos administradores conseguem ler suas mensagens criptografadas, chaves de API ou secrets."
Análise de Custo-Benefício
Custos
| Item | Esforço | Risco |
|---|---|---|
| Implementação WebCrypto | 2 semanas de dev | Médio (criptografia é difícil) |
| UX de gerenciamento de chaves | 1 semana de design + dev | Alto (confusão do usuário) |
| Mecanismo de recuperação | 1 semana de dev | Alto (UX errada = perda de dados) |
| Migração (dual-write) | 3 dias de dev | Baixo (compat. com versões anteriores) |
| Teste de penetração | $5.000 | — |
| Carga de suporte | +20% de tickets | Médio ("perdi minha recovery key") |
Total: ~6 semanas de dev + $5k de auditoria externa.
Benefícios
| Benefício | Impacto |
|---|---|
| Confiança do usuário | Alto — "privacidade de dados" é a principal preocupação de empresas |
| Conformidade regulatória | Conformidade com GDPR Art. 25 (obrigatória para clientes da UE) |
| Proteção contra violação | Mesmo violação catastrófica → blobs criptografados (dano mínimo) |
| Vantagem competitiva | Poucas plataformas de IA oferecem E2EE (Notion, ClickUp, Monday = texto plano) |
| Vendas enterprise | Obrigatório para setores de saúde, finanças, jurídico (HIPAA, SOC 2) |
Veredicto: Benefícios superam os custos. E2EE é requisito básico para plataformas de IA enterprise.
Métricas de Sucesso
Phase 45.1 (Fundação)
- Fluxo de login: 100% dos usuários derivam master key com sucesso
- sessionStorage: 0 vazamentos de chave no DevTools (auditoria de segurança)
- Performance: derivação PBKDF2 < 500ms no dispositivo mediano
Phase 45.2 (Criptografia de Chat)
- Adoção: 80% das mensagens criptografadas em 30 dias
- Performance: Criptografar/descriptografar < 10ms por mensagem (p95)
- Auditoria SQL:
SELECT content FROM chat_messages WHERE content IS NOT NULL→ 0 linhas (após migração)
Phase 45.3 (Secrets)
- Chaves de API: 100% armazenadas como blobs criptografados
- TOTP: Verificação client-side funciona para 100% dos usuários 2FA
- UI de configurações: 0 chaves em texto plano visíveis na aba Network
Phase 45.4 (Recuperação)
- Taxa de sucesso na recuperação: > 95% (dos usuários que tentam a recuperação)
- Tickets de suporte: "Perdi minha recovery key" < 5% do total de tickets
- Downloads de PDF: 90% dos usuários baixam o PDF de recuperação
Phase 45.5 (Hardening)
- Violações CSP: 0 (console do browser limpo)
- Auditoria de log:
grep -E '(sk-ant|sk-proj|@)' /var/log/citadel/→ 0 correspondências - Teste de penetração: 0 descobertas críticas, < 3 descobertas médias
Riscos e Mitigação
| Risco | Probabilidade | Impacto | Mitigação |
|---|---|---|---|
| Usuário esquece senha + perde recovery key → dados perdidos | Média | CRÍTICO | 1. Aviso assustador durante setup 2. Enviar PDF de recuperação por email automaticamente 3. Opcional: Imprimir QR code da recovery key |
| Bug no WebCrypto → dados corrompidos | Baixa | ALTO | 1. Testes unitários extensivos 2. Dual-write durante rollout (mantém backup em texto plano) 3. Auditoria de segurança externa |
| Degradação de performance (PBKDF2 lento em dispositivos antigos) | Média | Médio | 1. Iterações adaptativas (detecta velocidade do dispositivo) 2. Web Worker para derivação não-bloqueante |
| Incompatibilidade de browser (bugs do Safari) | Baixa | Médio | 1. Testar no Safari 15+, Chrome, Firefox 2. Fallback: polyfill para browsers antigos (ou bloqueá-los) |
| Carga de suporte ("não consigo acessar meus dados") | Alta | Médio | 1. Docs abrangentes 2. UI do wizard de recuperação 3. Email proativo: "Você salvou sua recovery key?" |
Apêndice: Primitivos Criptográficos
PBKDF2-SHA256
Propósito: Desacelerar ataques de força bruta em senhas.
Parâmetros:
- Iterações: 100.000 (recomendação OWASP 2025)
- Salt: Fixo por tipo de derivação (
citadel-auth-v1,citadel-master-v1) - Saída: Chave de 256 bits
Segurança: Com 100k iterações, o atacante consegue tentar ~1000 senhas/seg em GPU de ponta (vs. 1M/seg para SHA256 puro).
AES-GCM
Propósito: Criptografia autenticada (confidencialidade + integridade).
Parâmetros:
- Tamanho da chave: 256 bits
- Tamanho do IV: 96 bits (12 bytes, aleatório por mensagem)
- Tag de autenticação: 128 bits (16 bytes, anexado ao ciphertext)
Segurança: O AES-256 é resistente a computação quântica até ~2040 (estimativa NIST). O modo GCM previne adulteração (qualquer flip de bit → falha na descriptografia).
bcrypt
Propósito: Hashing de senha (server-side).
Parâmetros:
- Custo: 12 rounds (4096 iterações)
- Salt: 128 bits aleatórios por usuário
Por que hash duplo? O browser envia bcrypt(PBKDF2(password)) → o servidor armazena bcrypt(receivedHash). Mesmo que o DB do servidor vaze, o atacante precisa reverter DOIS hashes bcrypt.
Elaborado por Sentinel (Arquiteto de Segurança). Aprovado para implementação: CEO (2026-04-24).