Fase 45: Arquitectura Zero-Knowledge E2EE
Arc OS — "No podemos leer tus datos aunque quisiéramos" Autor: Sentinel (Arquitecto de Seguridad) | Fecha: 2026-04-24 Estado: DONE ✅ (2026-04-28) — Issues #16-#20 completos Aprobado por: CEO
Checklist de Implementación
- ✅ 45.1 — Wrapper WebCrypto (
frontend/src/crm/crypto/e2ee.ts, 214 líneas) - ✅ 45.2 — Cifrado de campos en vault (
shared/vault.ts: encryptField/decryptField/isFieldEncrypted) - ✅ 45.3 — Cifrado en reposo de mensajes de chat (migración 015, auto-cifrado/descifrado en db.ts)
- ✅ 45.4 — Claves de recuperación (migración 016, 4 endpoints API, UI RecoveryKeySection)
- ✅ 45.5 — Headers CSP/seguridad + sanitizador PII (
shared/pii-sanitizer.ts) - 🔲 45.6 — Avanzado (sincronización multi-dispositivo, forward secrecy, retención de datos) — P2 diferido
El Problema
Estado actual (Fase 43): El servidor almacena los datos del usuario en texto plano.
-- data/citadel.db (SQLite, archivo sin cifrar)
CREATE TABLE chat_messages (
content TEXT NOT NULL -- ❌ "sk-ant-abc123xyz (mi clave API)"
);
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" (seed 2FA)
);
Escenarios de ataque:
- Brecha en la base de datos — atacante descarga
citadel.db→ lee todos los mensajes, claves API, seeds TOTP - Compromiso del servidor — atacante obtiene acceso SSH →
sqlite3 citadel.db .dump→ exportación completa de datos - Amenaza interna — admin con acceso root → lee conversaciones de usuarios
- Filtración de backup — backup de DB sin cifrar subido a la nube → expuesto
- Obligación legal — orden judicial fuerza la entrega de datos → todos los datos de usuario son legibles
Veredicto actual: Seguro para multi-tenancy (los usuarios no pueden acceder a datos de otros), NO seguro para la privacidad del usuario (el servidor puede leer todo).
La Visión: Arquitectura Zero-Knowledge
Principio: El servidor es no confiable. Incluso con acceso SSH root + acceso a la base de datos, no podemos descifrar los datos del usuario.
Qué cambia:
ANTES (Fase 43):
Usuario escribe mensaje → servidor almacena en texto plano → admins pueden leerlo ❌
DESPUÉS (Fase 45):
Usuario escribe mensaje → browser cifra → servidor almacena blob → admins NO PUEDEN leerlo ✅
La promesa:
"Tus datos están cifrados con una clave derivada de tu contraseña. No tenemos tu contraseña, así que no podemos descifrar tus datos — aunque quisiéramos."
Modelo: Signal Protocol (simplificado), ProtonMail, 1Password.
Análisis de Decisión: Modelos de Cifrado
Opción A: Cifrado del Lado del Servidor (Solo en Reposo)
Usuario → servidor → cifra con clave del servidor → almacena en DB
| Criterio | Valoración | Detalles |
|---|---|---|
| Seguridad contra externo | Media | Archivo de base de datos cifrado, pero el servidor tiene la clave → acceso root = puede descifrar |
| Seguridad contra admin | CERO | Admin tiene clave del servidor → acceso completo |
| Cumplimiento (GDPR) | Débil | "Protección de datos" pero no zero-knowledge |
| Complejidad | Baja | SQLCipher o PRAGMA key |
| Recuperación de clave | Fácil | El servidor tiene la clave → sin acción del usuario |
Veredicto: Protege contra disco robado, NO contra compromiso del servidor o amenaza interna.
Opción B: Cifrado a Nivel de Aplicación (Lado del Servidor)
Usuario → servidor → cifra con clave vault → almacena en DB
El servidor tiene la clave vault (AES-256-GCM en vault.json)
| Criterio | Valoración | Detalles |
|---|---|---|
| Seguridad contra externo | Media | Mejor que texto plano, pero clave vault en el mismo servidor |
| Seguridad contra admin | CERO | Admin tiene vault.json → descifra todo |
| Cumplimiento | Débil | Sigue sin ser zero-knowledge |
| Complejidad | Media | Wrappers de cifrado a nivel de campo |
| Recuperación de clave | Fácil | Gestionado por el servidor |
Veredicto: Marginalmente mejor que la Opción A. Sigue fallando la prueba de "amenaza interna".
Opción C: Cifrado Extremo a Extremo (Zero-Knowledge) — ELEGIDA
Usuario → browser cifra con clave maestra (NUNCA enviada al servidor) → servidor almacena blob
El servidor no puede descifrar (no tiene clave)
| Criterio | Valoración | Detalles |
|---|---|---|
| Seguridad contra externo | ALTA | Datos cifrados + sin clave = inútil |
| Seguridad contra admin | ALTA | Admin tiene la base de datos pero no puede descifrar |
| Cumplimiento (GDPR) | EXCELENTE | Verdadera minimización de datos (Art. 25) |
| Complejidad | ALTA | API WebCrypto, gestión de claves, UX de recuperación |
| Recuperación de clave | DIFÍCIL | Usuario pierde contraseña = datos perdidos (requiere clave de recuperación) |
Veredicto: Única opción que ofrece zero-knowledge. El costo de complejidad vale la pena para la privacidad del usuario.
Resumen de Arquitectura
1. Derivación de Dos Claves (Diseño Split-Brain)
Problema: La contraseña se envía al servidor para auth. ¿Cómo derivar la clave de cifrado sin enviar la contraseña?
Solución: Derivar DOS claves de la contraseña con diferentes sales.
Contraseña del Usuario: "MySecurePass123!"
│
├─ PBKDF2(password, salt="citadel-auth-v1", 100k iter)
│ ↓
│ authBits (256-bit)
│ ↓
│ bcrypt(authBits, cost=12) → authHash
│ ↓
│ ENVIADO AL SERVIDOR (para verificación de login)
│
└─ PBKDF2(password, salt="citadel-master-v1", 100k iter)
↓
masterKey (clave de cifrado AES-256-GCM)
↓
PERMANECE EN EL BROWSER (sessionStorage)
NUNCA enviado al servidor
Propiedad de seguridad: Compromiso del servidor → atacante obtiene authHash → no puede derivar masterKey (sal diferente = salida diferente).
Código (frontend/src/crypto/e2ee.ts):
// Hash de auth (enviado al servidor)
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
);
// El servidor hará bcrypt de esto de nuevo para almacenarlo
return Buffer.from(bits).toString("hex");
}
// Clave maestra (guardada en el 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, // NO extraíble (no puede filtrarse)
["encrypt", "decrypt"]
);
return masterKey;
}
2. Flujo de Registro
┌─────────────────────────────────────────────────────────────┐
│ Registro de Usuario (browser) │
├─────────────────────────────────────────────────────────────┤
│ 1. Usuario introduce: email, contraseña │
│ │
│ 2. Browser deriva: │
│ authHash = PBKDF2(password, "auth-salt") → bcrypt │
│ masterKey = PBKDF2(password, "master-salt") → clave AES │
│ │
│ 3. POST /api/auth/register │
│ { │
│ email: "[email protected]", │
│ authHash: "2a12...bcrypt..." ← almacenado en DB │
│ } │
│ │
│ 4. Servidor: │
│ - bcrypt(authHash) → password_hash (doble hash) │
│ - INSERT INTO users (email, password_hash) │
│ - Enviar email de verificación │
│ │
│ 5. Browser: │
│ - Guardar masterKey en sessionStorage │
│ - Generar clave de recuperación (cifrar masterKey) │
│ - Mostrar clave de recuperación al usuario: ⚠️ ¡GUÁRDALA! │
│ "A83Z-KL9P-MM4X-VN2Q-8JC7" │
│ - Usuario descarga PDF │
│ │
│ 6. POST /api/crm/account/recovery-key │
│ { │
│ encrypted_master_key: base64(...), ← blob AES-GCM │
│ recovery_key_iv: base64(...) │
│ } │
└─────────────────────────────────────────────────────────────┘
3. Flujo de Login
┌─────────────────────────────────────────────────────────────┐
│ Login de Usuario (browser) │
├─────────────────────────────────────────────────────────────┤
│ 1. Usuario introduce: email, contraseña │
│ │
│ 2. Browser deriva: │
│ authHash = PBKDF2(password, "auth-salt") → bcrypt │
│ masterKey = PBKDF2(password, "master-salt") → clave AES │
│ │
│ 3. POST /api/auth/login │
│ { │
│ email: "[email protected]", │
│ authHash: "2a12..." │
│ } │
│ │
│ 4. Servidor: │
│ - Obtiene user.password_hash de DB │
│ - bcrypt.compare(authHash, password_hash) │
│ - Si coincide: genera token JWT │
│ - Devuelve { token, user_id } │
│ │
│ 5. Browser: │
│ - Guarda JWT en localStorage (para auth de API) │
│ - Guarda masterKey en sessionStorage (para descifrado) │
│ - Listo para cifrar/descifrar datos │
└─────────────────────────────────────────────────────────────┘
4. Cifrar y Enviar Mensaje
┌─────────────────────────────────────────────────────────────┐
│ Enviar Mensaje de Chat (browser) │
├─────────────────────────────────────────────────────────────┤
│ 1. Usuario escribe: "Deploy to production with sk-ant-xyz123" │
│ │
│ 2. Browser cifra: │
│ plaintext = "Deploy to production with sk-ant-xyz123" │
│ iv = crypto.getRandomValues(12 bytes) ← nonce aleatorio │
│ 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 │
│ // SIN campo de contenido en texto plano │
│ } │
│ │
│ 4. Servidor: │
│ - Verifica JWT (usuario autorizado) │
│ - INSERT INTO chat_messages ( │
│ project_name, │
│ worker_id, │
│ role = 'user', │
│ content_encrypted = BLOB, ← opaco para el servidor │
│ content_iv = BLOB, │
│ timestamp │
│ ) │
│ - Devuelve { message_id } │
│ │
│ 5. El servidor NO PUEDE leer "sk-ant-xyz123" — está cifrado │
└─────────────────────────────────────────────────────────────┘
5. Recibir y Descifrar Mensaje
┌─────────────────────────────────────────────────────────────┐
│ Obtener Historial de Chat (browser) │
├─────────────────────────────────────────────────────────────┤
│ 1. GET /api/crm/projects/arc-v2/chat/history │
│ Authorization: Bearer <JWT> │
│ │
│ 2. Servidor: │
│ - Verifica JWT + propiedad (canAccessProject) │
│ - SELECT content_encrypted, content_iv, timestamp │
│ FROM chat_messages │
│ WHERE project_name = 'arc-v2' │
│ ORDER BY timestamp DESC │
│ LIMIT 50 │
│ - Devuelve [{ content_encrypted, content_iv, ... }] │
│ │
│ 3. Browser descifra CADA mensaje: │
│ for (const msg of messages) { │
│ const ciphertext = base64Decode(msg.content_encrypted); │
│ const iv = base64Decode(msg.content_iv); │
│ const plaintext = AES-GCM.decrypt( │
│ ciphertext, │
│ masterKey, ← de sessionStorage │
│ iv │
│ ); │
│ displayMessage(plaintext); │
│ } │
│ │
│ 4. Usuario ve: "Deploy to production with sk-ant-xyz123" │
│ El servidor vio: blob sin sentido (8a9f3c...) │
└─────────────────────────────────────────────────────────────┘
Cambios en el Esquema de la Base de Datos
Migración 010: Campos E2EE
-- Migración: Añadir columnas cifradas, deprecar texto plano
-- 1. Mensajes de chat
ALTER TABLE chat_messages
ADD COLUMN content_encrypted BLOB,
ADD COLUMN content_iv BLOB,
ADD COLUMN key_version INTEGER DEFAULT 1;
-- Marcar contenido antiguo como deprecated (se re-cifrará en el próximo acceso)
-- NO eliminar columna `content` todavía (compatibilidad hacia atrás durante migración)
-- 2. Configuración de cuenta (claves 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. Usuarios (secrets TOTP)
ALTER TABLE users
ADD COLUMN totp_secret_encrypted BLOB,
ADD COLUMN totp_secret_iv BLOB;
-- 4. Claves de recuperación
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, -- pista opcional (primeros 4 chars: "A83Z-****-****")
created_at TEXT NOT NULL,
last_used_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- 5. Log de rotación de clave de cifrado
CREATE TABLE key_versions (
version INTEGER PRIMARY KEY,
created_at TEXT NOT NULL,
deprecated_at TEXT,
notes TEXT -- ej., "Rotación anual", "Incidente de seguridad"
);
INSERT INTO key_versions (version, created_at) VALUES (1, datetime('now'));
Gestión de Claves
Generación de Clave de Recuperación
Problema: El usuario olvida la contraseña → masterKey perdida → todos los datos irrecuperables.
Solución: Clave de recuperación (generada una sola vez, guardada offline por el usuario).
// Generar clave de recuperación (browser)
async function generateRecoveryKey(masterKey: CryptoKey): Promise<string> {
// 1. Generar clave aleatoria de 128 bits
const recoveryBytes = crypto.getRandomValues(new Uint8Array(16));
// 2. Codificar como cadena legible (Base32, sin caracteres ambiguos)
// Resultado: "A83Z-KL9P-MM4X-VN2Q-8JC7" (5 grupos de 4 chars)
const recoveryKey = base32Encode(recoveryBytes, { groups: 5 });
// 3. Derivar clave AES de los bytes de recuperación
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, // menos iteraciones (la recuperación ya es aleatoria)
hash: "SHA-256"
},
recoveryKeyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt"]
);
// 4. Exportar clave maestra (para cifrado)
const masterKeyExport = await crypto.subtle.exportKey("raw", masterKey);
// 5. Cifrar clave maestra con clave de recuperación
const iv = crypto.getRandomValues(new Uint8Array(12));
const encryptedMasterKey = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
recoveryAESKey,
masterKeyExport
);
// 6. Enviar al servidor para almacenamiento
await fetch('/api/crm/account/recovery-key', {
method: 'POST',
body: JSON.stringify({
encrypted_master_key: base64Encode(encryptedMasterKey),
recovery_key_iv: base64Encode(iv)
})
});
// 7. Devolver clave de recuperación al usuario (MOSTRAR UNA SOLA VEZ)
return recoveryKey; // "A83Z-KL9P-MM4X-VN2Q-8JC7"
}
Flujo de recuperación (contraseña olvidada):
┌─────────────────────────────────────────────────────────────┐
│ Recuperación de Contraseña │
├─────────────────────────────────────────────────────────────┤
│ 1. Usuario hace clic en "Olvidé mi contraseña" │
│ │
│ 2. Browser muestra: │
│ "Introduce tu clave de recuperación:" │
│ [____-____-____-____-____] │
│ │
│ 3. Usuario introduce: A83Z-KL9P-MM4X-VN2Q-8JC7 │
│ │
│ 4. Obtener clave maestra cifrada del servidor: │
│ GET /api/crm/account/[email protected] │
│ → { encrypted_master_key, recovery_key_iv } │
│ │
│ 5. Descifrar clave maestra: │
│ recoveryAESKey = deriveKey(recoveryKey) │
│ masterKey = AES-GCM.decrypt( │
│ encrypted_master_key, │
│ recoveryAESKey, │
│ recovery_key_iv │
│ ) │
│ │
│ 6. Solicitar NUEVA contraseña: │
│ "Establece una nueva contraseña:" │
│ [________] (debe ser diferente de la anterior) │
│ │
│ 7. Derivar nuevo authHash de la nueva contraseña │
│ │
│ 8. Actualizar servidor: │
│ PUT /api/auth/reset-password │
│ { recovery_key, new_auth_hash } │
│ │
│ 9. Servidor actualiza password_hash │
│ │
│ 10. Browser guarda masterKey en sessionStorage │
│ → Usuario recupera acceso a datos cifrados │
└─────────────────────────────────────────────────────────────┘
Rate limiting: Máx. 5 intentos de recuperación por hora (previene fuerza bruta).
Propiedades de Seguridad
Contra Qué Protegemos
| Amenaza | Mitigación | Nivel |
|---|---|---|
Brecha en la base de datos (archivo .db robado) |
✅ Datos cifrados, sin claves | Completo |
| Compromiso del servidor (SSH root) | ✅ Sin claves maestras en el servidor | Completo |
| Amenaza interna (abuso de admin) | ✅ Admin no puede descifrar | Completo |
| Ataque MITM (interceptación de red) | ✅ HTTPS + payload cifrado | Completo |
| Filtración de backup (S3, Drive) | ✅ Los backups solo contienen blobs | Completo |
| Obligación legal (orden judicial) | ✅ No se puede descifrar sin contraseña del usuario | Completo |
| Ataque XSS (robar clave maestra) | ✅ Headers CSP + sin JS inline | Parcial |
| Fuerza bruta de contraseña | ✅ bcrypt + PBKDF2 100k iteraciones | Fuerte |
Contra Qué NO Protegemos (Fuera del Alcance)
| Amenaza | Responsabilidad del Usuario |
|---|---|
| Robo de dispositivo físico (portátil desbloqueado) | Bloqueo de pantalla, cifrado de disco completo |
| Extensión de browser maliciosa | Revisar extensiones, usar solo las confiables |
| Keylogger en el dispositivo | Antivirus, dispositivo seguro |
| Ingeniería social (phishing de clave de recuperación) | Concienciación en seguridad |
| Computación cuántica (rotura de AES-256) | No factible hasta ~2040 (cronograma NIST) |
Hoja de Ruta de Implementación
Fase 45.1: Fundación (Issues #39) — 2 semanas
Objetivo: Wrapper WebCrypto, derivación de claves funcionando.
- Módulo
frontend/src/crypto/e2ee.ts - Función
deriveAuthHash(password) - Función
deriveMasterKey(password) - Función
encrypt(plaintext, masterKey) - Función
decrypt(ciphertext, iv, masterKey) - Tests unitarios (Jest): cifrado round-trip
- Auditoría de seguridad: sin filtración de claves en DevTools
Aceptación: El login deriva ambas claves, clave maestra en sessionStorage.
Fase 45.2: Cifrado de Chat (Issue #40) — 1 semana
Objetivo: Mensajes de chat con E2EE.
- Migración 010: columnas
content_encrypted,content_iv - Workspace.jsx: cifrar antes de
handlePostMessage - Workspace.jsx: descifrar después de obtener historial
- Backend: almacenar blobs, sin validación de texto plano
- Test de rendimiento: < 10ms por cifrado/descifrado de mensaje
Aceptación: El chat funciona, el servidor no puede leer mensajes (query SQL muestra blobs).
Fase 45.3: Cifrado de Secretos (Issues #41-#42) — 1 semana
Objetivo: Claves API + seeds TOTP cifrados.
- AccountSettings.jsx: cifrar claves API antes de guardar
- AccountSettings.jsx: descifrar al cargar, enmascarar en UI (
sk-***xyz) - Configuración TOTP: cifrar seed antes de guardar
- Verificación TOTP: lado del cliente (el servidor no puede verificar OTP)
- Migración: cifrar claves en texto plano existentes (una sola vez)
Aceptación: La UI de configuración funciona, el servidor no puede leer claves.
Fase 45.4: Recuperación de Clave (Issues #43-#44) — 1 semana
Objetivo: Clave de recuperación + exportación GDPR.
- UI de generación de clave de recuperación (advertencia escalofriante)
- Descarga de PDF (clave de recuperación + instrucciones)
- UI de flujo de recuperación (contraseña olvidada → introducir clave → nueva contraseña)
- Rate limiting: 5 intentos/hora
- Exportación de datos: descifrado del lado del cliente → descarga JSON
Aceptación: El usuario puede recuperar su cuenta con la clave de recuperación.
Fase 45.5: Hardening (Issues #45-#46) — 3 días
Objetivo: CSP, sanitización de logs.
- Headers CSP en Nginx (
script-src 'self', sin'unsafe-inline') - Eliminar todos los manejadores de eventos inline (auditoría:
grep -r onClick=) - Hashes SRI para Google Fonts
- Sanitización de logs (función
sanitize()en logger.ts) - Script de auditoría:
grepen logs para buscar emails/claves API
Aceptación: Violaciones de CSP = 0, logs limpios.
Estrategia de Testing
Tests Unitarios (Jest)
describe('E2EE', () => {
test('deriveAuthHash es determinista', async () => {
const hash1 = await deriveAuthHash('password123');
const hash2 = await deriveAuthHash('password123');
expect(hash1).toBe(hash2);
});
test('deriveMasterKey es determinista', 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('round-trip encrypt → decrypt', 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('clave incorrecta no puede descifrar', 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('ciphertext manipulado falla (etiqueta de auth GCM)', async () => {
const key = await deriveMasterKey('test');
const { ciphertext, iv } = await encrypt('Secret', key);
ciphertext[0] ^= 0xFF; // invertir un bit
await expect(decrypt(ciphertext, iv, key)).rejects.toThrow();
});
});
Tests de Integración
Registro → Login → Descifrado:
- Registrarse con contraseña → cerrar sesión → iniciar sesión → verificar que se pueden descifrar mensajes antiguos
Flujo de recuperación:
- Registrarse → guardar clave de recuperación → cerrar sesión → contraseña olvidada → recuperar → verificar que los datos son accesibles
Simulación multi-dispositivo:
- Login en "dispositivo 1" (Chrome) → enviar mensaje
- Login en "dispositivo 2" (Firefox) → verificar que se puede descifrar el mismo mensaje
Pentest Externo (Presupuesto $5k)
Escenarios:
- Inyección de payload XSS → intentar exfiltrar clave maestra
- Volcado de base de datos → verificar que no hay contenido de chat en texto plano
- Ataque MITM → verificar que los blobs cifrados son resistentes a manipulación (etiqueta GCM)
- Fuerza bruta de clave de recuperación → verificar que el rate limiting es efectivo
- Ataque de timing de canal lateral → verificar que PBKDF2 es de tiempo constante
Cronograma: Tras completar #39-#42 (Q3 2026).
Estrategia de Migración
Compatibilidad hacia Atrás
Problema: Los usuarios existentes tienen datos en texto plano. No se puede romper su acceso.
Solución: Migración gradual.
Paso 1: Escritura Dual (Fase 45.2)
// Backend: escribe AMBOS texto plano y cifrado
async function handlePostMessage(req) {
const { content, content_encrypted, content_iv } = req.body;
db.run(`
INSERT INTO chat_messages (
content, -- texto plano antiguo (deprecated)
content_encrypted, -- nuevo blob E2EE
content_iv
) VALUES (?, ?, ?)
`, [
content || null, // null si es cliente E2EE
content_encrypted,
content_iv
]);
}
// Frontend: envía AMBOS (durante la transición)
const { ciphertext, iv } = await encrypt(plaintext, masterKey);
await fetch('/api/chat', {
body: JSON.stringify({
content: plaintext, // para API antigua
content_encrypted: ciphertext, // para nuevo E2EE
content_iv: iv
})
});
Paso 2: Preferencia de Lectura (Fase 45.2)
// Backend: devuelve cifrado si disponible, si no texto plano
const messages = db.query(`SELECT * FROM chat_messages`).all();
for (const msg of messages) {
if (msg.content_encrypted) {
// Mensaje E2EE (preferido)
yield { content_encrypted: msg.content_encrypted, content_iv: msg.content_iv };
} else {
// Texto plano legacy (deprecated, advertir al usuario)
yield { content: msg.content, legacy: true };
}
}
// Frontend: descifrar si es E2EE, si no mostrar texto plano con advertencia
if (msg.content_encrypted) {
const plaintext = await decrypt(msg.content_encrypted, msg.content_iv, masterKey);
displayMessage(plaintext);
} else {
displayMessage(msg.content);
showWarning('Este mensaje fue enviado antes de que se habilitara E2EE. ¿Volver a cifrarlo?');
}
Paso 3: Herramienta de Re-Cifrado (Fase 45.3)
// Configuración → Seguridad → "Cifrar Mensajes Antiguos"
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('¡Todos los mensajes cifrados!');
}
// Backend: actualiza el mensaje, BORRA el texto plano
app.put('/api/chat/messages/:id/encrypt', (req) => {
db.run(`
UPDATE chat_messages
SET content_encrypted = ?,
content_iv = ?,
content = NULL ← BORRAR texto plano
WHERE id = ?
`, [req.content_encrypted, req.content_iv, req.params.id]);
});
Paso 4: Eliminar Columna de Texto Plano (Fase 46)
-- Después de 90 días, verificar 0 mensajes en texto plano
SELECT COUNT(*) FROM chat_messages WHERE content IS NOT NULL;
-- Si es 0, eliminar columna
ALTER TABLE chat_messages DROP COLUMN content;
Consideraciones de UX
Advertencias Honestas (Aunque Aterradoras)
Generación de clave de recuperación:
┌────────────────────────────────────────────────────────────┐
│ ⚠️ CRÍTICO: Guarda Tu Clave de Recuperación │
├────────────────────────────────────────────────────────────┤
│ Tus datos están cifrados con una clave que SOLO TÚ tienes.│
│ Si pierdes tu contraseña Y esta clave de recuperación, │
│ tus datos se PERDERÁN PERMANENTEMENTE. NO podemos │
│ recuperarlos por ti. │
│ │
│ Clave de Recuperación: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ A83Z-KL9P-MM4X-VN2Q-8JC7 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ [ Descargar PDF ] [ Imprimir ] [ Copiar ] │
│ │
│ ☐ He guardado esta clave de recuperación en un lugar seguro │
│ │
│ [ Continuar ] ← deshabilitado hasta marcar la casilla │
└────────────────────────────────────────────────────────────┘
Primer login tras habilitar E2EE:
┌────────────────────────────────────────────────────────────┐
│ 🔒 Cifrado Extremo a Extremo Activado │
├────────────────────────────────────────────────────────────┤
│ Tus mensajes, claves API y secretos ahora se cifran en │
│ tu dispositivo antes de enviarse a nuestros servidores. │
│ │
│ ✓ NO PODEMOS leer tus datos (aunque quisiéramos) │
│ ✓ Brecha en BD = el atacante obtiene blobs inútiles │
│ ✗ Contraseña olvidada + clave de recuperación perdida = datos perdidos │
│ │
│ [ Más información ] [ Entendido ] │
└────────────────────────────────────────────────────────────┘
Impacto en Cumplimiento
Artículos GDPR
| Artículo | Antes (Fase 43) | Después (Fase 45) | Mejora |
|---|---|---|---|
| Art. 25 (Protección de datos por diseño) | Almacenamiento en texto plano | E2EE por defecto | ✅ Conforme |
| Art. 32 (Seguridad del procesamiento) | JWT + bcrypt | + AES-256 E2EE | ✅ Mejorado |
| Art. 17 (Derecho al olvido) | Eliminar de DB | + cifrado (ya inutilizable) | ✅ Más sólido |
| Art. 20 (Portabilidad de datos) | Exportación del servidor | Descifrado del cliente + exportación | ✅ Controlado por usuario |
Afirmaciones de Marketing (Requieren Revisión Legal)
Puedes decir:
- ✅ "Cifrado extremo a extremo"
- ✅ "Arquitectura zero-knowledge"
- ✅ "No podemos leer tus datos"
- ✅ "Ni siquiera nuestros admins pueden descifrar tus mensajes"
No puedes decir:
- ❌ "Inhackeable" (nada lo es)
- ❌ "Cifrado de grado militar" (marketing vacío, riesgo legal)
- ❌ "100% seguro" (XSS, compromiso del dispositivo siguen siendo posibles)
Recomendado:
"Tus datos están cifrados de extremo a extremo usando el estándar industrial AES-256-GCM. Usamos una arquitectura zero-knowledge: el cifrado ocurre en tu dispositivo y nunca tenemos acceso a tus claves de descifrado. Ni siquiera nuestros administradores pueden leer tus mensajes cifrados, claves API o secretos."
Apéndice: Primitivas Criptográficas
PBKDF2-SHA256
Propósito: Ralentizar los ataques de fuerza bruta a contraseñas.
Parámetros:
- Iteraciones: 100.000 (recomendación OWASP 2025)
- Sal: Fija por tipo de derivación (
citadel-auth-v1,citadel-master-v1) - Salida: clave de 256 bits
Seguridad: Con 100k iteraciones, un atacante puede probar ~1000 contraseñas/seg en una GPU de gama alta (frente a 1M/seg con SHA256 simple).
AES-GCM
Propósito: Cifrado autenticado (confidencialidad + integridad).
Parámetros:
- Tamaño de clave: 256 bits
- Tamaño de IV: 96 bits (12 bytes, aleatorio por mensaje)
- Etiqueta de autenticación: 128 bits (16 bytes, añadida al ciphertext)
Seguridad: AES-256 es resistente a la computación cuántica hasta ~2040 (estimación NIST). El modo GCM previene la manipulación (cualquier cambio de bit → el descifrado falla).
bcrypt
Propósito: Hash de contraseñas (lado del servidor).
Parámetros:
- Coste: 12 rondas (4096 iteraciones)
- Sal: 128 bits aleatorios por usuario
¿Por qué doble hash? El browser envía bcrypt(PBKDF2(password)) → el servidor almacena bcrypt(receivedHash). Incluso si la DB del servidor se filtra, el atacante debe revertir DOS hashes bcrypt.
Redactado por Sentinel (Arquitecto de Seguridad). Aprobado para implementación: CEO (2026-04-24).