HouseKey Vault
Inspiración
Todo empezó con una pregunta incómoda: ¿por qué tienes que confiar en que tu gestor de contraseñas no ha sido hackeado?
LastPass sufrió una brecha en 2022 en la que se filtraron vaults cifrados de millones de usuarios. 1Password, Bitwarden, Dashlane, todos almacenan tu vault en sus servidores. Todos te piden que confíes en que su implementación es correcta, en que sus servidores están bien protegidos, en que nunca tendrán un insider malicioso.
Nosotros no queremos que confíes en nosotros. Queremos que no necesites hacerlo.
La inspiración directa vino de cómo funciona la autenticación SSH: tienes una clave privada en tu máquina, el servidor tiene tu clave pública, y nunca mandas la clave privada a ningún sitio. ¿Por qué los gestores de contraseñas no funcionan así?
Qué construimos
HouseKey Vault es un gestor de contraseñas de conocimiento cero donde la autenticación se realiza íntegramente mediante criptografía de clave pública. No hay contraseña maestra. No hay secreto compartido con el servidor. No hay forma de que el servidor descifre tu vault aunque quiera.
El flujo principal
Al registrarte, tu navegador genera un par de claves ECDSA P-256 usando exclusivamente la Web Crypto API, in librerías externas. La clave privada se guarda en un fichero .hkv que tú controlas (un USB, tu móvil, tu Dropbox). La clave pública va al servidor.
Al hacer login, el servidor te manda un nonce de un solo uso. Tu navegador lo firma con tu clave privada. El servidor verifica la firma. Si es válida, devuelve tu vault cifrado, que solo tú puedes descifrar.
La criptografía
El vault se cifra con AES-256-GCM. La clave AES se deriva de la clave privada usando HKDF-SHA256 (RFC 5869):
$$\text{AES key} = \text{HKDF}(\text{IKM} = k_{priv},\ \text{salt} = \text{SHA-256}(k_{pub}),\ \text{info} = \texttt{"housekeyvault-vault-v2"})$$
El salt único por usuario y el label de dominio garantizan que la misma clave privada no puede producir el mismo material de clave en ningún otro contexto. Además, se genera un IV aleatorio de 96 bits en cada operación de cifrado, reutilizar el IV con AES-GCM es un error criptográfico clásico que elimina toda la seguridad del esquema.
La recuperación usa PBKDF2 con 200.000 iteraciones, diseñado para ser computacionalmente costoso:
$$\text{key}_{recovery} = \text{PBKDF2}(\text{pwd} = \text{seed phrase},\ \text{salt} = \texttt{"housekeyvault-recovery-v1"},\ i = 200000,\ \text{PRF} = \text{HMAC-SHA256})$$
Features que nos hacen diferentes
Duress Mode: Si alguien te fuerza a abrir tu vault, introduces un PIN de duress en lugar de tu PIN real. El cliente descifra un vault señuelo completamente vacío. El servidor procesa exactamente la misma autenticación ECDSA; no tiene ninguna forma de distinguir un login normal de uno bajo coacción. La clave del vault señuelo se deriva con un esquema diferente.
La clave del vault señuelo se deriva en tres pasos: primero se estira el PIN con PBKDF2 para obtener 256 bits de material criptográfico, luego esos bits se mezclan con los bytes de la clave privada mediante XOR, y finalmente ese material combinado pasa por HKDF con el label "housekeyvault-duress-v1" — diferente al "housekeyvault-vault-v2" del vault real. El resultado es que la misma clave privada produce dos claves AES completamente distintas según qué PIN se use, y nadie que no tenga ambos, el fichero .hkv y el PIN, puede derivar ninguna de las dos.
El XOR del IKM con el secreto derivado del PIN garantiza que nadie puede derivar la clave duress sin conocer ambos: el fichero .hkv y el PIN.
Forensic Watermarking: Cada fichero exportado lleva una huella invisible codificada en caracteres Unicode de ancho cero (U+200B, U+200C, U+FEFF). El watermark codifica los primeros 64 bits del hash de tu clave pública y el timestamp exacto del export. Si ese fichero aparece en una filtración, extractWatermark() identifica de qué cuenta salió y cuándo.
Secure Sharing: Las credenciales se comparten mediante intercambio de claves ECDH P-256 efímero. La clave de descifrado viaja solo en el fragmento # de la URL — la parte que el navegador nunca incluye en las peticiones HTTP. El servidor almacena el ciphertext pero no puede descifrarlo ni aunque tenga la base de datos completa.
Second Device Lock: Las entradas críticas requieren un segundo dispositivo físico. El fichero .hkv2 y una frase de emergencia derivan la misma clave AES usando el mismo salt — dos caminos independientes hacia la misma puerta.
Cómo lo construimos
El stack es intencionadamente minimalista: Next.js 15 con App Router, TypeScript, Supabase como base de datos, y Vercel para el deploy. Pero lo más importante es lo que no usamos: ninguna librería de criptografía externa. Toda la criptografía corre sobre la Web Crypto API nativa del navegador.
Esto no fue una decisión estética. Las librerías de criptografía de terceros son superficie de ataque — supply chain attacks, versiones desactualizadas, dependencias transitivas. Web Crypto es parte del navegador, auditada por los mismos equipos que auditan TLS, y sus claves marcadas como extractable: false son físicamente irrecuperables por JavaScript incluso si la página está comprometida.
La arquitectura es de una sola página (page.tsx) para minimizar la superficie de ataque del frontend. No hay rutas que expongan datos sensibles. Las rutas de API solo hablan JSON y están protegidas por cookies HttpOnly con SameSite Strict.
Retos que superamos
El problema del IV reutilizado. Durante el desarrollo inicial, encryptVault generaba el IV una vez en el registro y lo reutilizaba en cada save. Reutilizar el mismo IV con la misma clave en AES-GCM es catastrófico — permite recuperar el XOR de los plaintexts. Lo detectamos revisando el código antes de la presentación y lo corregimos generando un IV fresco en cada operación.
SHA-256 como KDF. La primera implementación derivaba la clave AES haciendo SHA-256(privateKey). Funciona, pero no es una KDF — no tiene salt, no tiene separación de dominio, y no es lo que recomienda ningún estándar. Lo reemplazamos con HKDF, que añade las tres propiedades que faltaban sin coste computacional notable.
El problema del closure en el breach check masivo. El HIBP check individual llamaba a persist() dentro de un bucle, y cada llamada a persist() capturaba vault.entries del closure en el momento de la primera iteración — sobreescribiendo los resultados de todas las iteraciones anteriores. Solo la última entrada quedaba marcada como breached. Lo corregimos acumulando todos los resultados en un Record<string,boolean> y haciendo un único persist() al final.
La danza de los tipos de Web Crypto. La Web Crypto API es notoriamente estricta con los tipos de buffer. Uint8Array<ArrayBufferLike> no es lo mismo que ArrayBuffer para importKey(), aunque ambos contienen los mismos bytes. Pasamos tiempo no trivial añadiendo funciones toPlain() que hacen buffer.slice() para garantizar que Web Crypto recibe exactamente el tipo que espera.
Lo que aprendimos
Que la criptografía correcta no es solo elegir los algoritmos correctos, es elegirlos con los parámetros correctos, en el orden correcto, con las primitivas correctas para cada capa. HKDF, PBKDF2 y SHA-256 son las tres maneras de derivar una clave a partir de un secreto, y son completamente diferentes entre sí. Entender cuándo usar cada una fue la lección más valiosa del hackathon.
También aprendimos que los threat models más interesantes no son los técnicos. El duress mode nació de preguntarnos: ¿qué pasa si el atacante eres tú mismo, bajo coacción? La respuesta criptográficamente correcta a esa pregunta resultó ser elegante: dos claves HKDF con labels diferentes, derivadas del mismo material base, indistinguibles entre sí para cualquier observador externo.
Built With
- aes-256-gcm
- api
- crypto
- ecdh
- ecdsa
- hkdf-sha256
- next.js
- p-256
- pbkdf2
- sql
- supabase
- typescript
- vercel
- web
Log in or sign up for Devpost to join the conversation.