Cuando el Overkill Es el Objetivo: Cifrado E2E de Grado Bancario para una App de Contabilidad Familiar
Implementé cifrado end-to-end en una app de finanzas personales — Argon2id, AES-256-GCM, envelope encryption, recuperación BIP39. Esto es lo que aprendí cuando el backend nunca ve tus datos.
Cuando el Overkill Es el Objetivo: Cifrado E2E de Grado Bancario para una App de Contabilidad Familiar
🤷 ¿Por Qué Hacer Esto?
Seamos honestos: nadie necesita cifrado de grado bancario para trackear los gastos del supermercado. Mi familia no es un target de la NSA. Pero cuando empecé a construir Home Account — una app de contabilidad familiar — me pregunté: ¿y si implemento E2E encryption de verdad? No porque lo necesite, sino porque quiero aprender cómo funciona.
Movimiento clásico de desarrollador. "Voy a sobreingenierar esto hasta que aprenda algo." Y aprendí. Mucho más de lo que esperaba.
El resultado: una app donde el backend nunca ve tus datos financieros. Ni el amount, ni la descripción, ni la categoría bancaria, ni nombres. El servidor almacena blobs cifrados y nada más. Si alguien roba la base de datos entera, obtiene basura inútil.
🔐 E2E Real: Por Qué CSR Fue la Única Opción
La filosofía es simple: el backend es un almacén tonto de blobs cifrados. Nunca descifra nada. Nunca ve nada en claro.
Esto tiene una consecuencia enorme para la arquitectura: Server Components son incompatibles con E2E. Un Server Component no tiene acceso al cryptoStore (que vive en memoria del navegador). Si haces fetch en el servidor, obtienes blobs cifrados — basura ilegible para renderizar.
La solución: Server Components para el shell (layout, navegación, estructura), Client Components para todo lo financiero. El initialData={} va vacío a propósito — no quieres que el servidor intente fetchear datos que no puede descifrar.
// Server Component — solo el shell
export default function AccountPage() {
return <AccountClient initialData={{}} /> // vacío, a propósito
}
// Client Component — aquí vive la magia
'use client'
export function AccountClient({ initialData }) {
const { cryptoStore } = useCryptoStore()
// Fetch → descifrar → renderizar. Todo client-side.
}
¿Y el rendimiento? Medí: descifrar 5000 transacciones tarda ~5-50ms. Para uso familiar (50-200 transacciones al mes), es imperceptible.
Modelo de amenazas — en todos estos escenarios, el atacante obtiene blobs inútiles:
| Ataque | ¿Qué obtiene? | ¿Datos en claro? | |--------|---------------|-------------------| | Robo de base de datos | Blobs cifrados AES-256 | No | | Compromiso del servidor | Blobs + metadata mínima | No | | Acceso físico al servidor | Lo mismo que arriba | No | | XSS (robo de cookies) | Session tokens, no claves | No (claves en memoria JS, no en cookies) | | MITM | Tráfico TLS + blobs cifrados | No |
🏗️ Envelope Encryption: El Patrón de Dos Niveles
Aquí es donde la cosa se pone interesante. No es solo "cifrar con una clave". Es un sistema de dos niveles que, cuando lo entiendes, te das cuenta de por qué absolutamente todo el mundo serio lo usa:
Password → Argon2id → UserKey (UK) → cifra → AccountKey (AK) → cifra → Datos
La primera vez que leí sobre envelope encryption, pensé "¿para qué tanto lío? Cifra directo con la contraseña y ya." Pero hay una razón elegante. Imagina 5 cuentas financieras con 3000 transacciones. Si cifras directo con tu contraseña y decides cambiarla, tienes que re-cifrar las 3000 transacciones. Minutos de procesamiento, y si algo falla a mitad del camino — datos corruptos. Con dos niveles, cambiar la contraseña solo significa re-cifrar 5 AccountKeys. Milisegundos. Atómico. Sin riesgo.
¿Por qué Argon2id y no PBKDF2? Argon2id es memory-hard: configuro 64MB de RAM por intento, con t=3 iteraciones y p=4 paralelismo. Un atacante con GPUs no puede hacer millones de intentos por segundo — cada intento necesita 64MB de memoria dedicada. PBKDF2 es CPU-bound, y las GPUs modernas destrozan CPU-bound.
¿Por qué AES-256-GCM? Porque GCM incluye autenticación integrada. Si alguien manipula un blob cifrado, GCM lo detecta y falla el descifrado. No necesitas un MAC separado. IV de 12 bytes, estándar actual, battle-tested. Si un solo bit cambia en el ciphertext, el descifrado se niega rotundamente a continuar.
El truco inteligente es el verification_blob: al crear la cuenta, cifro un texto fijo (HOME_ACCOUNT_VERIFIED_2026) con la UserKey. Cuando el usuario entra su secreto, intento descifrar ese blob. Si AES-GCM lo descifra correctamente → secreto válido. Si falla → wrong. La belleza es que verifico credenciales sin tocar ningún dato real — descifro un blob tiny de verificación y tengo la respuesta en microsegundos, en vez de descifrar todas las AccountKeys solo para saber si el PIN es correcto.
La arquitectura del código sigue una separación clara: crypto.ts es el motor puro — no sabe nada de React, Zustand ni base de datos. Solo cifra y descifra. cryptoStore.ts es el conductor — maneja estado con Zustand, coordina el flujo. Esta separación me salvó más de una vez: cuando necesité cambiar cómo se almacenaba el estado, no tuve que tocar ni una línea de la lógica criptográfica.
Y las claves son memory-only. F5 = claves perdidas. El usuario tiene que re-entrar su secreto. Tres estados: Unauthenticated → Authenticated+Locked → Authenticated+Unlocked. Parece inconveniente, pero es una capa de seguridad extra que sale gratis del modelo.
¿Qué se cifra y qué no? Cifro description, amount, bank_category, names. Dejo en claro date (necesario para ordenar en servidor), FKs (necesarios para JOINs), amount_sign (+1/-1, para filtrar ingresos/gastos sin descifrar), color, icon. La regla: si el servidor lo necesita para queries básicas, va en claro. Si es dato financiero sensible, va cifrado.
🔑 OAuth, PIN y la Gran Separación
La separación autenticación/cifrado fue la revelación más grande del proyecto. Y no exagero.
Al principio, en mi cabeza, autenticación y cifrado eran la misma cosa. Entras tu contraseña, la usas para todo. Pero cuando empecé a pensar en OAuth, esa idea se desmoronó. Google te autentica, sí, pero no te da un secreto del que puedas derivar una clave criptográfica. El token de OAuth cambia cada vez, es opaco, no puedes usarlo para cifrar nada de manera determinista.
Y ahí fue el click: autenticación y cifrado son dos problemas distintos que necesitan dos soluciones independientes. La contraseña (o OAuth) resuelve "¿eres tú?". El PIN resuelve "¿puedes ver tus datos?". Son ortogonales. Puedes estar autenticado pero locked — sabes quién es el usuario pero no puede ver nada cifrado hasta que entre su PIN.
Solución: un PIN separado de 6-8 dígitos, exclusivo para cifrado. Contraseña (autenticación, bcrypt en backend) y PIN (cifrado E2E, Argon2id en frontend) son completamente independientes. Dos sistemas, dos propósitos, dos algoritmos, ni siquiera corren en el mismo lugar — bcrypt en el servidor, Argon2id en el navegador.
El sistema detecta automáticamente la fuente de cifrado con verifyUserKey() — intenta descifrar el verification_blob con la clave derivada de la contraseña. Si funciona → la contraseña es la fuente. Si falla → existe un PIN. Transparente para el usuario.
"Pero 6 dígitos son solo ~1M combinaciones, eso es débil." Sí, solos sí. Pero con Argon2id (64MB por intento) + rate limiting (5 intentos cada 15 minutos), estamos hablando de años para brute force. Las matemáticas: 1M combinaciones × ~0.5s por intento de Argon2id = ~5.8 días teóricos sin rate limit. Con rate limit de 5/15min = ~2000 días. Fine para una app familiar.
El flujo OAuth queda: login exitoso → JWT válido → redirect a /unlock → usuario entra PIN → se deriva UK → se descifran AKs → acceso a datos. El flujo de registro incluye un crypto pre-setup: las claves se derivan antes de verificar email (endpoint público autorizado por token de registro), así el primer login va directo sin setup adicional.
Una vez que entiendes esta separación, todo el diseño encaja como un puzzle. ¿Dónde va bcrypt? Backend. ¿Dónde va Argon2id? Frontend. ¿Cambias de proveedor OAuth? El cifrado no se entera. ¿Cambias el PIN? La autenticación ni se inmuta. Una independencia que te da flexibilidad brutal para evolucionar cada sistema por separado.
🌐 El Proxy: Cookies Cross-Site y el Drama de SameSite
Esto me topó tarde en el desarrollo y me costó horas de frustración real. Tengo el frontend funcionando perfecto en local — login, cifrado, todo green. Llega el momento de deployar. Frontend en Vercel, backend en Render. Dominios distintos. Y de repente, nada funciona. Los requests llegan al backend sin cookies. El auth falla.
Pasé un buen rato mirando Network tabs, comparando headers, revisando CORS configs. Todo parecía correcto. Hasta que encontré la respuesta: SameSite=Lax es el default en todos los navegadores modernos. Las cookies httpOnly simplemente no se envían en requests cross-origin. No es un bug — es un feature de seguridad. Tiene todo el sentido del mundo, pero cuando te topa en producción sin haberlo previsto, es puro pain.
La solución: un proxy transparente en Next.js Route Handlers.
// /api/proxy/[...path]/route.ts
export async function POST(req: NextRequest) {
const cookies = req.cookies // cookies del dominio Vercel
const response = await fetch(`${API_URL}/${path}`, {
headers: {
Cookie: cookies.toString(), // reenvía al backend
'X-CSRF-Token': req.headers.get('X-CSRF-Token'),
},
})
// Reenvía Set-Cookie de vuelta al browser
return proxyResponse(response)
}
Para el navegador, todo ocurre en same-origin. El proxy lee cookies del dominio Vercel, las reenvía al backend Render como header Cookie, y reenvía Set-Cookie de vuelta. Incluye auto-refresh: si el backend responde 401, intenta refresh antes de reintentar — invisible para el usuario.
Tres cookies: accessToken (httpOnly, 15min), refreshToken (httpOnly, 8h), csrfToken (no httpOnly, 8h — accesible por JS para enviar en header). Y un API_URL dinámico: en cliente usa /api/proxy (same-origin), en servidor usa process.env.API_URL (directo).
Lección: piensa en el deployment desde el día uno. Es de esas cosas que no aparecen en ningún tutorial de "cómo hacer auth con Next.js" pero que te muerden en producción sin piedad.
🌱 BIP39 Recovery: El Seguro de Vida
El problema existencial del E2E es que si olvidas la contraseña, tus datos desaparecen para siempre. No hay "recuperar contraseña" porque el servidor no tiene tus claves. No hay un admin que pueda resetearte el acceso. No hay un support ticket que pueda salvarte. Game over real.
Piénsalo un momento. En una app normal, olvidar tu contraseña es un inconveniente de 30 segundos — click en "olvidé mi contraseña", email, link, listo. Con E2E real, olvidar tu contraseña significa que tus datos financieros de años se convierten en ruido criptográfico irrecuperable. Esa es una responsabilidad enorme que poner sobre el usuario, y me generó cierta ansiedad durante el diseño — ¿estoy construyendo algo que puede destruir los datos de mi propia familia?
Solución: 24 palabras mnemónicas BIP39 (256 bits de entropía, 2^256 combinaciones). El patrón es el mismo envelope encryption pero con un twist elegante: la misma AccountKey se cifra dos veces. Una con la UserKey (contraseña/PIN) y otra con la RecoveryKey (derivada del mnemónico). Dos llaves para el mismo candado. Si pierdes una, la otra sigue funcionando.
AccountKey cifrada con UserKey → uso diario
AccountKey cifrada con RecoveryKey → emergencia
El mnemónico nunca va al servidor. Solo el blob cifrado se almacena en la tabla recovery_keys. Setup obligatorio tras el primer login. Si necesitas recuperar: entras las 24 palabras → se deriva RecoveryKey → se descifran las AKs → se re-cifran con tu nuevo secreto.
La advertencia: el mnemónico es tan valioso como la contraseña. Debe guardarse en papel, nunca digital. Sé que suena old-school en 2026, pero hay una razón por la que Bitcoin lleva años usando este sistema — funciona. Si lo pierdes Y olvidas tu contraseña, tus datos se fueron. Para siempre. Esa es la naturaleza del E2E real — no hay backdoor.
🤝 Invitaciones: Compartir Sin Exponer Claves
El reto: dos usuarios no comparten contraseña. ¿Cómo transfiero la AccountKey al segundo usuario?
El token de invitación (256 bits, 64 hex chars) actúa como clave temporal. El invitador cifra la AK con el token: encrypt(AK, token). El invitado recibe el link con el token, descifra la AK, y la re-cifra con su propia UserKey.
Trade-off aceptado: el servidor tiene el token y el blob cifrado en la misma fila durante 24 horas. Es una ventana temporal y el token se invalida tras aceptación. SELECT ... FOR UPDATE en la transacción de aceptación para evitar race conditions. Rate limiting de 3 invitaciones por 24 horas. Verificación de email del invitado antes de aceptar.
¿Es perfecto? No. Un compromiso del servidor durante esas 24h expondría esa AK específica. Pero para una app familiar con invitaciones poco frecuentes, el trade-off es aceptable y está documentado.
🛡️ Defense in Depth: Las Capas que No Ves
CSRF: Double-submit cookie con comparación timing-safe. El csrfToken es httpOnly=false (accesible por JS para enviar en header), los JWTs son httpOnly=true. Protege todas las mutaciones (POST/PUT/DELETE).
XSS: DOMPurify + JSDOM en backend. Empecé con regex porque parecía suficiente — patrones para stripear <script>, atributos on*, y ya. Funcionaba en mis tests. Hasta que descubrí los unicode homoglyphs: caracteres que se ven idénticos a letras ASCII pero tienen codepoints distintos. Un atacante puede construir un <scrіpt> usando una "і" cirílica (U+0456) en lugar de la "i" latina, y tu regex no lo atrapa. Hay cientos de estos bypasses documentados. La conclusión: regex para sanitización de HTML es un juego que siempre pierdes. DOMPurify parsea el DOM de verdad, entiende la estructura del HTML, y lleva años siendo battle-tested. CSP headers restrictivos como última línea. React escapa outputs por defecto — no usamos dangerouslySetInnerHTML con contenido dinámico.
Refresh token seguro: el endpoint rechaza explícitamente refresh tokens enviados en body con 400. Solo acepta el de la cookie httpOnly. Previene robo vía XSS (un script malicioso podría leer un token del body, pero no de una cookie httpOnly).
Importación de Excel/CSV: validación de magic bytes (XLSX es un ZIP: PK\x03\x04), cellFormula y cellHTML desactivados (previene formula injection), límites estrictos (5MB, 10K filas, 100 columnas, 500K celdas), deduplicación con import_hash — SHA-256 calculado client-side desde date|description|amount antes de cifrar.
Prompt injection en módulo IA: el módulo de IA necesita ver datos descifrados temporalmente para analizarlos, lo que abre una superficie de ataque interesante: ¿qué pasa si alguien mete un prompt injection en la descripción de una transacción? Construí una defensa con 38 regex patterns que detectan patrones comunes de injection ("ignore previous instructions", "you are now", etc.) más 6 heurísticas que analizan entropía, longitud anómala, y concentración de tokens de control. Cuatro niveles de severidad determinan si se bloquea, se sanitiza, o se permite con warning. Multi-proveedor (Groq/Ollama/Claude/Gemini). Lo más importante: los datos financieros son efímeros — nunca persisten en el contexto de la IA. Logging seguro que nunca registra contenido del prompt ni la respuesta.
⚖️ Los Trade-offs: Lo Que Acepté Conscientemente
Aquí viene la parte honesta. E2E encryption no es gratis. Tiene costos reales, y algunos duelen más de lo que esperaba.
No hay queries en servidor sobre datos cifrados. Esto es lo que más cuesta aceptar cuando vienes del mundo SQL tradicional. No puedes hacer SUM(amount) ni LIKE '%supermercado%' en SQL. Todo se hace client-side tras descifrar — reduce() y map() en JavaScript. Se siente wrong al principio, como dar un paso atrás. Pero para 50-200 transacciones al mes, es fine. Para un banco con millones de registros, sería inviable. Pero esto no es un banco — es la contabilidad de mi familia.
Presupuestos: el backend solo guarda el límite (DECIMAL no cifrado). El frontend descifra transacciones, agrupa por subcategory_id, suma amounts y compara con el límite. La lógica de presupuestos vive entera en el cliente.
DECIMAL de MySQL llega como string en JS. Esta trampa me costó un bug real. Los números se veían bien en la base de datos, bien en el response del API, pero los totales eran absurdos. "100.50" + "200.30" en JavaScript es "100.50200.30" — concatenación de strings, no suma aritmética. El driver de MySQL devuelve DECIMAL como string para no perder precisión, y JavaScript felizmente concatena sin quejarse. Siempre Number() antes de aritmética. Siempre.
amount_sign en claro (+1/-1) permite filtrar ingresos/gastos en servidor sin descifrar amounts. Es un leak mínimo de metadata — un atacante sabría cuántas transacciones son ingresos vs gastos, pero no los montos ni las descripciones. A cambio, el servidor puede hacer queries tipo "dame solo los gastos de este mes" sin descifrar nada.
Gráficos con Recharts: calculados client-side sobre datos descifrados. No hay charts pre-computados en servidor.
PWA con Serwist: cachea assets estáticos y shell, pero no cachea llamadas API (los datos cifrados deben venir frescos del servidor siempre).
Lo que descarté conscientemente: virtualización, Web Workers para descifrado, gzip para blobs. Para 50-200 transacciones/mes (~3000 en 10 años), no lo necesito. Si algún día lo necesito, está documentado dónde añadirlo.
La honestidad absoluta: esto es overkill. Lo sé desde el día uno. Pero el objetivo nunca fue optimizar — fue aprender criptografía aplicada de verdad, con todas sus consecuencias. Y eso lo cumplió de sobra.
🔄 Lo Que Haría Diferente
- Pensar en el proxy/deployment desde el día uno. SameSite me mordió tarde y dolió. Si planificas el deployment antes de escribir código, te ahorras horas.
- Documentar decisiones de seguridad mientras las tomas, no reconstruirlas después. Intentar recordar por qué elegiste AES-256-GCM sobre ChaCha20 tres meses después es pain.
- BIP39 debería haber sido parte del diseño inicial, no un añadido posterior. Añadir recovery después requirió refactorear el flujo de creación de cuentas entero.
- Nunca guardar el password en sessionStorage. Lo hacía para auto-unlock (comodidad), lo eliminé en febrero 2026. SessionStorage es accesible por XSS. Mala idea desde el principio.
💡 Lecciones Aprendidas (Las Reales)
-
E2E te obliga a pensar diferente. El backend se convierte en un almacén tonto. No puedes hacer queries inteligentes. No puedes agregar datos. Cambias la forma en que diseñas APIs, modelos y flujos enteros.
-
La separación autenticación/cifrado fue la revelación más grande. Bcrypt en backend para auth, Argon2id en frontend para cifrado. Dos sistemas independientes. Una vez que lo entiendes, todo el diseño encaja.
-
Los navegadores te obligan al proxy si quieres cookies cross-site. Aprende esto antes de empezar, no después de deployar. SameSite no es opcional — es el default en todos los browsers modernos.
-
DOMPurify > cualquier regex que se te ocurra. Siempre. Sin excepciones. Tu regex "perfecta" tiene un bypass que no conoces.
-
El overkill deliberado es una herramienta de aprendizaje válida. No habría aprendido envelope encryption, BIP39, ni Argon2id construyendo un TODO app. A veces necesitas un proyecto ambicioso para crecer.
-
Memory-only keys son consecuencia del modelo, no un feature. Pero resultan en una capa de seguridad extra que no planifiqué. F5 = claves perdidas = sesión cifrada cerrada. Happy accident.
-
Rate limiting es esencial en TODOS los endpoints sensibles, no solo login. Invitaciones, recovery, cambio de PIN, verificación — todo necesita rate limiting. Si no lo tienes, alguien lo abusará.
Bottom line: ¿Necesitaba mi familia cifrado de grado bancario para trackear los gastos del Mercadona? Absolutamente no. ¿Aprendí más sobre criptografía, seguridad y arquitectura en este proyecto que en cualquier curso? Absolutamente sí. A veces el overkill es el objetivo. Y cuando lo es, constrúyelo con toda la intención del mundo.
El código no miente — o cifras de verdad, o no cifras. No hay medias tintas con E2E. Y eso es exactamente lo que lo hace un ejercicio de aprendizaje tan brutal.
Keep building, keep learning. 🔐