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


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:

  1. Brecha en la base de datos — atacante descarga citadel.db → lee todos los mensajes, claves API, seeds TOTP
  2. Compromiso del servidor — atacante obtiene acceso SSH → sqlite3 citadel.db .dump → exportación completa de datos
  3. Amenaza interna — admin con acceso root → lee conversaciones de usuarios
  4. Filtración de backup — backup de DB sin cifrar subido a la nube → expuesto
  5. 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 authHashno 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.

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.

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.

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.

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.

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

  1. Registro → Login → Descifrado:

    • Registrarse con contraseña → cerrar sesión → iniciar sesión → verificar que se pueden descifrar mensajes antiguos
  2. Flujo de recuperación:

    • Registrarse → guardar clave de recuperación → cerrar sesión → contraseña olvidada → recuperar → verificar que los datos son accesibles
  3. 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:

  1. Inyección de payload XSS → intentar exfiltrar clave maestra
  2. Volcado de base de datos → verificar que no hay contenido de chat en texto plano
  3. Ataque MITM → verificar que los blobs cifrados son resistentes a manipulación (etiqueta GCM)
  4. Fuerza bruta de clave de recuperación → verificar que el rate limiting es efectivo
  5. 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:

No puedes decir:

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:

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:

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:

¿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).