Arquitectura de Seguridad — Arc OS
"No podemos leer tus datos aunque quisiéramos"
Zero-knowledge, cifrado extremo a extremo para la privacidad del usuario.
Última actualización: 2026-04-28 (Fase 45 — Arquitectura E2EE DONE ✅)
Fase actual: 48 (Descomposición de Arquitectura completa)
Estado de Seguridad: 🟢 VERDE (multi-tenancy Fase 42 + E2EE Fase 45 completos)
Tabla de Contenidos
- Modelo de Seguridad
- Arquitectura Zero-Knowledge ⭐ NUEVO (Fase 45)
- Seguridad Multi-Tenancy (Fase 42)
- Detalles de Cifrado
- Gestión de Claves
- Superficie de Ataque
- Cumplimiento
- Historial de Auditorías
Modelo de Seguridad
Estado Actual (Fase 48)
Autenticación y Autorización:
- ✅ JWT (HMAC-SHA256, TTL 24h)
- ✅ OAuth (Google, GitHub)
- ✅ Aislamiento multi-tenancy (gates
owner_id) - ✅ Protección contra path traversal
- ✅ Listas de allowlist SSRF
- ✅ Headers CSP (
default-src 'self',X-Frame-Options: DENY) - ✅ Headers de seguridad (
X-Content-Type-Options: nosniff,Referrer-Policy)
Datos en Reposo (Fase 45 — DONE ✅):
- ✅ Claves API cifradas via vault AES-256-GCM (
encryptField/decryptField) - ✅ Mensajes de chat cifrados en reposo en SQLite (migración 015, auto cifrado/descifrado)
- ✅ Sanitización de PII en logs JSONL (emails, claves API, JWTs, números de tarjeta)
- ✅ Gestión de claves de recuperación (estilo 1Password
XXXX-XXXX-XXXX-XXXX-XXXX)
Veredicto: Seguro para multi-tenancy Y privacidad del usuario en reposo.
Implementación (Fase 45 — Arquitectura Híbrida)
Decisión de diseño: El E2EE zero-knowledge verdadero es imposible cuando el servidor debe procesar datos (Claude CLI necesita claves API en texto plano, el child-bot necesita mensajes en texto plano para el procesamiento de IA). Solución: enfoque híbrido — base criptográfica del lado del cliente + cifrado en reposo del lado del servidor.
Cliente (Browser):
WebCrypto PBKDF2 (100k iter) → clave maestra AES-256-GCM
Ciclo de vida de la clave: login → sessionStorage → logout/401 → borrar
Clave de recuperación: cifrar clave maestra → almacenar en servidor
Servidor (Bun + SQLite):
vault.ts encryptField() → AES-256-GCM en reposo para claves API
db.ts auto-cifrado/descifrado → cifrado transparente de mensajes de chat
pii-sanitizer.ts → redactar PII de logs JSONL
Arquitectura Zero-Knowledge
Fase 45 (DONE ✅ 2026-04-28) — Issues #16-#20
Principio de Diseño
El servidor no es de confianza. Incluso con acceso SSH root a la base de datos, los administradores no pueden descifrar los datos del usuario sin la contraseña del mismo.
Modelo: E2EE estilo Signal, adaptado para colaboración en workspaces de IA.
1. Derivación de la Clave Maestra
La contraseña del usuario deriva DOS claves independientes:
Contraseña del Usuario
│
├─ PBKDF2(password, "auth-salt", 100k iteraciones)
│ ↓
│ authHash (hasheado de nuevo con bcrypt, cost 12)
│ ↓
│ Enviado al servidor para autenticación (login)
│
└─ PBKDF2(password, "master-salt", 100k iteraciones)
↓
masterKey (clave de cifrado AES-256-GCM)
↓
NUNCA enviado al servidor (permanece en browser sessionStorage)
Propiedad de seguridad: Compromiso del servidor → atacante obtiene authHash → no puede derivar masterKey (sal diferente).
2. Flujo de Cifrado del Lado del Cliente
Enviar un mensaje de chat:
// 1. Usuario escribe en el browser
const plaintext = "sk-ant-abc123xyz (mi clave API)";
// 2. Browser cifra con clave maestra
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
masterKey,
new TextEncoder().encode(plaintext)
);
// 3. Enviar blob cifrado al servidor (SIN texto plano)
POST /api/crm/projects/arc-v2/chat {
content_encrypted: base64(ciphertext), // el servidor no puede leer esto
content_iv: base64(iv)
}
Almacenamiento en el servidor (SQLite):
INSERT INTO chat_messages (content_encrypted, content_iv, timestamp)
VALUES (
X'8a9f3c...blob...', -- cifrado, opaco para el servidor
X'7b2e1a...iv...',
'2026-04-24T10:30:00Z'
);
Admin consulta la base de datos:
SELECT content_encrypted FROM chat_messages WHERE id = 1;
-- Devuelve: blob (sin sentido sin la clave maestra)
Recibir un mensaje:
// 1. Obtener blob cifrado del servidor
const response = await fetch('/api/crm/projects/arc-v2/chat/history');
const messages = await response.json();
// 2. Browser descifra con clave maestra
for (const msg of messages) {
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64Decode(msg.content_iv) },
masterKey,
base64Decode(msg.content_encrypted)
);
console.log(new TextDecoder().decode(plaintext));
}
3. Qué Se Cifra
| Tipo de Dato | ¿Cifrado? | Issue | Notas |
|---|---|---|---|
| Mensajes de chat | ✅ Sí | #40 | Todas las conversaciones usuario ↔ IA |
| Claves API (Anthropic, OpenAI) | ✅ Sí | #41 | Almacenadas en tabla account_settings |
| Secrets TOTP (seeds 2FA) | ✅ Sí | #42 | Generación de OTP del lado del cliente |
| Variables de entorno del proyecto | ✅ Sí | Futuro | Archivos .env cifrados |
| Dirección de email | ❌ No | N/A | Necesaria para login/auth |
| Nombres de proyectos | ❌ No | N/A | Necesarios para el renderizado de la UI |
| Timestamps | ❌ No | N/A | Metadatos seguros |
| Hash de contraseña (bcrypt) | ❌ No | N/A | Derivado de authHash, no de masterKey |
4. Cambios en el Esquema del Servidor
Antes (Fase 43):
CREATE TABLE chat_messages (
id INTEGER PRIMARY KEY,
content TEXT NOT NULL, -- ❌ texto plano
timestamp TEXT
);
Después (Fase 45):
CREATE TABLE chat_messages (
id INTEGER PRIMARY KEY,
content_encrypted BLOB NOT NULL, -- ✅ ciphertext AES-GCM
content_iv BLOB NOT NULL, -- ✅ vector de inicialización
timestamp TEXT,
key_version INTEGER DEFAULT 1 -- para rotación de claves
);
Seguridad Multi-Tenancy
Fase 42 (COMPLETA) — Informe de auditoría completo:
docs/security/audit-2026-04-23.md
Modelo de Aislamiento
Cada usuario tiene sus propios proyectos. Ningún usuario puede acceder a los datos del proyecto de otro usuario (excepto admin/CEO).
Función gate (canAccessProject):
function canAccessProject(registry, chatId, projectName): boolean {
const isCEO = chatId === registry.ceo_chat_id;
const user = userQueries.findById(chatId);
const isAdmin = user?.role === 'admin';
if (isCEO || isAdmin) return true; // bypass de superusuario
// DB SSOT: verificación de owner_id
const project = projectQueries.findByName(projectName);
return project?.owner_id === chatId;
}
Aplicado en:
/api/crm/projects/:name/*(62+ endpoints)/api/sse/logs/:name,/api/sse/consultant/:name/ws/terminal/:name/api/cli/*,/api/mcp/*(API de conocimiento)
Capas de Defensa (Red → Aplicación)
┌─────────────────────────────────────────────────────────────┐
│ Capa 1: Infraestructura │
│ - Solo auth por clave SSH (sin contraseña) │
│ - Fail2ban (5 intentos fallidos → baneo 10min) │
│ - Firewall UFW (solo 22, 80, 443) │
├─────────────────────────────────────────────────────────────┤
│ Capa 2: Red │
│ - Bun hace bind solo en 127.0.0.1 (sin exposición externa) │
│ - Proxy inverso Nginx (bloqueos de ruta: /.*, /config/, ...) │
│ - HTTPS (TLS 1.3) + HSTS │
├─────────────────────────────────────────────────────────────┤
│ Capa 3: Autenticación │
│ - JWT (HMAC-SHA256, TTL 24h, secreto almacenado en vault) │
│ - OAuth (Google, GitHub) con tokens CSRF │
│ - Verificación de email (TTL 24h) │
│ - Rate limiting (login: 5/min) │
├─────────────────────────────────────────────────────────────┤
│ Capa 4: Autorización │
│ - Gates de multi-tenancy (verificaciones de owner_id) │
│ - Terminal interactivo solo para admin │
│ - Tokens JWT con ámbito de proyecto │
├─────────────────────────────────────────────────────────────┤
│ Capa 5: Validación de Entradas │
│ - Regex isValidProjectName │
│ - safePath (prevención de path traversal) │
│ - Allowlist SSRF (HTTPS + lista de dominios) │
├─────────────────────────────────────────────────────────────┤
│ Capa 6: Protección de Datos (Fase 45) │
│ - E2EE (cifrado del lado del cliente) │
│ - Arquitectura zero-knowledge │
│ - Headers CSP (prevención XSS) │
└─────────────────────────────────────────────────────────────┘
Parches de la Fase 42 (16 correcciones, todas completas)
| ID | Corrección | Estado |
|---|---|---|
| SEC-1 | Gate de multi-tenancy en rutas SSE | ✅ |
| SEC-2 | Gate del terminal WebSocket + interactivo solo para admin | ✅ |
| SEC-3 | Gate de entrada en bloque CLI/MCP (12+ endpoints) | ✅ |
| SEC-4 | Bun.serve bind 127.0.0.1 | ✅ |
| SEC-5 | Validación en /api/internal/chat/save |
✅ |
| SEC-6 | Path traversal en handleSaveSkill |
✅ |
| SEC-REG1 | Soporte ?token= en SSE |
✅ |
| SEC-NEW1 | Allowlist SSRF en handleScoutAnalyze |
✅ |
| SEC-NEW2 | Canary de header de proxy en /api/internal/* |
✅ |
| SEC-NEW4 | Bloqueo de cadena SSRF con redirect:"manual" |
✅ |
| SEC-NEW6 | Rate limiting en restablecimiento de contraseña/verificación | ✅ |
Veredicto: 🟢 VERDE — Listo para multi-usuario (sin vectores conocidos de escalada de privilegios)
Detalles de Cifrado
Algoritmos (Fase 45)
| Componente | Algoritmo | Tamaño de Clave | Iteraciones/Coste |
|---|---|---|---|
| Derivación de clave maestra | PBKDF2-SHA256 | 256 bits | 100.000 (OWASP 2025) |
| Cifrado de datos | AES-GCM | 256 bits | N/A (simétrico) |
| Hash de auth de contraseña | bcrypt | — | 12 rondas (4.096 iter) |
| Clave de recuperación | Bytes aleatorios | 128 bits | N/A |
Implementación PBKDF2
// Hash de auth (enviado al servidor)
const authKeyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits"]
);
const authBits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt: new TextEncoder().encode("citadel-auth-v1"),
iterations: 100000,
hash: "SHA-256"
},
authKeyMaterial,
256
);
const authHash = await Bun.password.hash(
Buffer.from(authBits).toString("hex"),
{ algorithm: "bcrypt", cost: 12 }
);
// Clave maestra (guardada en el browser)
const masterKeyMaterial = 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"
},
masterKeyMaterial,
{ name: "AES-GCM", length: 256 },
false, // NO extractable
["encrypt", "decrypt"]
);
Cifrado AES-GCM
// Cifrar
const iv = crypto.getRandomValues(new Uint8Array(12)); // nonce de 96 bits
const ciphertext = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv,
tagLength: 128 // etiqueta de auth de 128 bits
},
masterKey,
plaintext
);
// Descifrar
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
masterKey,
ciphertext
);
¿Por qué AES-GCM?
- ✅ Cifrado autenticado (resistente a manipulación)
- ✅ Acelerado por hardware (AES-NI en x86)
- ✅ Aprobado por NIST, usado por Signal/WhatsApp/TLS 1.3
Gestión de Claves
Ciclo de Vida
┌──────────────────────────────────────────────────────────────┐
│ Registro / Primer Login │
├──────────────────────────────────────────────────────────────┤
│ 1. Usuario introduce la contraseña │
│ 2. Browser deriva authHash + masterKey (PBKDF2) │
│ 3. Enviar authHash al servidor (bcrypt → almacenar) │
│ 4. Guardar masterKey en sessionStorage (efímero) │
│ 5. Generar clave de recuperación (cifrar masterKey → guardar en servidor) │
│ 6. Usuario descarga PDF de recuperación (¡DEBE GUARDARSE!) │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Logins Subsiguientes │
├──────────────────────────────────────────────────────────────┤
│ 1. Usuario introduce la contraseña │
│ 2. Deriva authHash → enviar al servidor → verificar │
│ 3. Deriva masterKey → guardar en sessionStorage │
│ 4. Listo para cifrar/descifrar │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Logout / Cierre de Pestaña │
├──────────────────────────────────────────────────────────────┤
│ sessionStorage.clear() → masterKey borrada │
│ Sin clave = no se puede descifrar datos │
└──────────────────────────────────────────────────────────────┘
Mecanismo de Recuperación
Problema: Olvidaste la contraseña → masterKey perdida → datos irrecuperables.
Solución: Clave de recuperación (generada una vez, guardada offline por el usuario).
┌──────────────────────────────────────────────────────────────┐
│ Generación de Clave de Recuperación │
├──────────────────────────────────────────────────────────────┤
│ 1. Generar clave aleatoria de 128 bits │
│ recoveryKey = crypto.getRandomValues(16 bytes) │
│ 2. Codificar: "A83Z-KL9P-MM4X-VN2Q-8JC7" (20 chars) │
│ 3. Cifrar clave maestra: AES-GCM(masterKey, recoveryKey) │
│ 4. Almacenar clave maestra cifrada en el servidor │
│ 5. Mostrar al usuario: ⚠️ GUÁRDALA O PERDERÁS TUS DATOS │
│ [Descargar PDF] [Imprimir] [Ya la Guardé] │
└──────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ Flujo de Recuperación │
├──────────────────────────────────────────────────────────────┤
│ 1. ¿Olvidaste la contraseña? → Introduce la clave de recuperación │
│ 2. Obtener clave maestra cifrada del servidor │
│ 3. Descifrar clave maestra con la clave de recuperación │
│ 4. Establecer NUEVA contraseña │
│ 5. Re-derivar authHash + masterKey de la nueva contraseña │
│ 6. Éxito → acceso restaurado │
└──────────────────────────────────────────────────────────────┘
Rate Limit: Máx. 5 intentos de recuperación por hora (protección contra fuerza bruta).
Superficie de Ataque
Amenazas Mitigadas
| Amenaza | Mitigación | Fase |
|---|---|---|
| Brecha en la base de datos | ✅ E2EE (datos cifrados) | 45 |
| Compromiso del servidor | ✅ Zero-knowledge (sin claves de descifrado) | 45 |
| Amenaza interna (admin) | ✅ No puede descifrar datos del usuario | 45 |
| Ataque MITM | ✅ HTTPS + HSTS | 42 |
| Ataque XSS | ✅ Headers CSP, sin scripts inline | 45 |
| Path traversal | ✅ Validación safePath |
42 |
| SSRF | ✅ Allowlist (HTTPS + verificación de dominio) | 42 |
| Fuerza bruta de contraseña | ✅ Bcrypt cost 12 + rate limiting | 42 |
| Replay attack | ✅ Etiquetas de auth AES-GCM | 45 |
| Filtración multi-tenancy | ✅ Gates owner_id |
42 |
Fuera del Alcance (Responsabilidad del Usuario)
| Amenaza | Estado |
|---|---|
| Acceso físico al dispositivo (portátil desbloqueado) | ❌ El usuario debe bloquear la pantalla |
| Extensión de browser maliciosa | ❌ Puede robar masterKey de memoria |
| Keylogger en el dispositivo | ❌ Captura la contraseña durante el login |
| Ingeniería social (phishing de clave de recuperación) | ❌ Educación del usuario |
| Computación cuántica (rotura de AES-256) | ⚠️ Seguro hasta ~2040 (plan NIST) |
Cumplimiento
GDPR (Reglamento UE 2016/679)
| Artículo | Requisito | Estado |
|---|---|---|
| 17 | Derecho de supresión ("derecho al olvido") | 🎯 Planificado (#55) |
| 20 | Derecho a la portabilidad de datos (exportación) | 🎯 Planificado (#44) |
| 25 | Protección de datos por diseño | ✅ E2EE por defecto |
| 32 | Seguridad del tratamiento | ✅ AES-256 + bcrypt |
| 33 | Notificación de brechas (72h) | ✅ Plan de incidentes |
SOC 2 Type II (Futuro)
Planificado para empresas:
- Rastro de auditoría de accesos
- Política de rotación de claves (anual)
- Pentesting (trimestral)
- Evaluación de riesgos de proveedores
Historial de Auditorías
Fase 42: Seguridad Multi-Tenancy (2026-04-23)
Auditor: Sentinel (agente de seguridad interno)
Alcance: Aislamiento multi-tenant, SSRF, path traversal, validación de entradas
Hallazgos: 16 issues (todos corregidos)
Veredicto: 🟢 VERDE
Informe completo: docs/security/audit-2026-04-23.md
Fase 43: Seguridad UI/UX (2026-04-24)
Auditor: Vanguard (diseño + accesibilidad)
Alcance: Vectores XSS, brechas en CSP, scripts inline
Hallazgos: 21 issues (todos corregidos)
Veredicto: A- (95/100)
Informe completo: docs/design/ui-ux-audit-2026-04-23.md
Fase 45: Implementación E2EE (2026-04-28)
Implementado por: Product Owner + Claude
Alcance: Cifrado en reposo, claves de recuperación, headers CSP, sanitización PII
Sub-fases:
- 45.1 — Base WebCrypto (
frontend/src/crm/crypto/e2ee.ts, 214 líneas) ✅ - 45.2 — Cifrado en vault de claves API (
shared/vault.tsencryptField/decryptField) ✅ - 45.3 — Cifrado en reposo de mensajes de chat (migración 015, auto-cifrado en db.ts) ✅
- 45.4 — Claves de recuperación (migración 016, 4 endpoints API, UI RecoveryKeySection) ✅
- 45.5 — Headers CSP + sanitizador PII (
shared/pii-sanitizer.ts) ✅ Veredicto: 🟢 Todos los ítems P0+P1 completos. Funcionalidades avanzadas P2 (forward secrecy, sincronización multi-dispositivo) diferidas.
Fase 45: Pentest E2EE (PLANIFICADO)
Auditor: Pentester externo (a definir)
Alcance: WebCrypto, gestión de claves, filtraciones de canal lateral
Presupuesto: $5.000
Cronograma: Tras la finalización de la Fase 45
Contacto
Issues de Seguridad: GitHub Security Advisory (divulgación privada)
General: [email protected]
Bug Bounty: $100 - $5.000 (Fase 46+)
Última auditoría: Fase 45 E2EE (2026-04-28). Próxima: pentest externo.