Saltar al contenido

Criptografía del almacenamiento de seeds en el navegador

·7 min de lectura·- visitas

Hay una tensión que vale la pena examinar: una seed phrase BIP-39 codifica 256 bits de entropía. Un PIN de 6 dígitos tiene aproximadamente 20. Sin embargo, en la mayoría de las wallets de navegador, el PIN es la puerta entre un atacante y la seed. ¿Cómo se justifica eso?

La respuesta es key stretching, y entender por qué funciona, dónde falla y qué dicen realmente las matemáticas es más interesante que la implementación.

El problema de la entropía

256 bits de entropía significan 2^256 seeds posibles. Fuerza bruta sobre ese espacio no es un problema computacional, es un problema físico. El universo observable contiene unos 10^80 átomos, el espacio de búsqueda es de 10^77. Se acaba la materia antes de agotar las combinaciones.

Un PIN de 6 dígitos tiene 10^6 combinaciones, unos 20 bits. Ese es un problema completamente distinto. Si un atacante roba el blob cifrado y puede probar PINs a velocidad arbitraria, encontrará el correcto en menos de un millón de intentos. La pregunta es cuánto cuesta cada intento. Ese es todo el juego.

La jerarquía de claves en dos capas

Antes de entrar en las matemáticas, la arquitectura que se sostiene dentro de las restricciones del navegador luce así:

Jerarquía de claves en dos capas: PIN, PBKDF2, KEK, device key y seed cifrada

Dos capas independientes. Romper una no rompe la otra. La razón de esta separación no es solo defensa en profundidad, tiene una consecuencia práctica: rotar el PIN solo re-envuelve la Device Key. El ciphertext de la seed en disco nunca se toca. La seed en texto plano no necesita entrar en memoria durante un cambio de PIN.

PBKDF2: frenando al atacante

PBKDF2, Password-Based Key Derivation Function 2, RFC 8018, toma una contraseña de baja entropía y aplica una función pseudoaleatoria iterativamente para producir una clave criptográfica. La construcción para cada bloque de salida es:

U₁ = PRF(password, salt || i)
U₂ = PRF(password, U₁)
U₃ = PRF(password, U₂)
...
Uₙ = PRF(password, Uₙ₋₁)

block_i = U₁ ⊕ U₂ ⊕ ... ⊕ Uₙ

Donde n es el conteo de iteraciones y PRF es HMAC-SHA-256. Cada evaluación de HMAC-SHA-256 implica dos compresiones SHA-256, por lo que 600.000 iteraciones de PBKDF2 significan 1.200.000 operaciones SHA-256 por intento de PIN.

En un núcleo de CPU moderno con un throughput de SHA-256 de aproximadamente 250 MB/s, una llamada PBKDF2 a 600.000 iteraciones toma aproximadamente 300 a 500 ms. Un atacante agotando 10^6 PINs en un solo núcleo: 3 a 6 días.

El salt es crítico. Es un valor aleatorio de 128 bits incluido en la primera ronda de PBKDF2. Dos PINs idénticos en dispositivos distintos producen claves diferentes. Las rainbow tables precomputadas se vuelven inútiles porque cada dispositivo tiene un salt único. El atacante debe ejecutar la computación completa de PBKDF2 desde cero para cada objetivo.

Donde PBKDF2 falla: GPUs

El problema con PBKDF2 es que no es memory-hard. Cada llamada HMAC-SHA-256 usa una cantidad pequeña y fija de estado de trabajo, aproximadamente 64 a 128 bytes. Esto significa que la computación se paraleliza limpiamente en miles de núcleos GPU sin penalización.

Paralelismo CPU vs GPU para el cracking de PINs con PBKDF2

Una GPU de gama media puede ejecutar 10.000 o más operaciones SHA-256 en paralelo. A 600.000 iteraciones y 10.000 hilos paralelos, el tiempo por intento se reduce por un factor de 10.000. Lo que tomó 6 días en CPU toma aproximadamente 50 segundos en GPU. Esto no es teórico. Herramientas como Hashcat tienen implementaciones de PBKDF2-SHA-256 aceleradas por GPU.

La solución principiada es Argon2, el ganador del Password Hashing Competition en 2015. Argon2id, la variante recomendada, requiere una cantidad configurable de memoria por computación. Con 64 MB de memoria por hilo, una GPU con 8 GB de VRAM solo puede ejecutar aproximadamente 128 instancias paralelas. El requerimiento de memoria neutraliza la ventaja del paralelismo.

Argon2 no forma parte de la Web Crypto API del navegador. Existe una implementación en WASM que se usa en producción en herramientas como KeeWeb, pero incluirla agrega tamaño al bundle y extiende la superficie de ataque en un contexto donde minimizar el código ejecutable es en sí mismo una preocupación de seguridad. PBKDF2 con un conteo alto de iteraciones es la restricción pragmática de trabajar dentro de la interfaz criptográfica nativa del navegador.

AES-GCM: por qué la autenticación importa

Una vez que se tiene una clave derivada, se necesita un modo de cifrado. AES-GCM, Galois/Counter Mode, es la elección correcta, y entender por qué requiere separar dos propiedades que a menudo se confunden: confidencialidad e integridad.

AES-CBC proporciona confidencialidad. Cada bloque de texto plano se XORea con el bloque de ciphertext anterior antes del cifrado. Pero no ofrece garantía de integridad. Un atacante puede invertir bits en el ciphertext y producir cambios predecibles en la salida descifrada. El padding oracle attack explota esto: al modificar el ciphertext y observar si el descifrado produce un padding válido o inválido, un atacante puede descifrar el mensaje completo sin la clave. La solución es encrypt-then-MAC, un HMAC sobre el ciphertext después del cifrado, pero implementar esto correctamente es sutil. El HMAC debe cubrir también el IV, y la comparación debe ser en tiempo constante. Aquí es donde la mayoría de las implementaciones fallan.

AES-GCM incorpora la integridad de fábrica. Opera en dos componentes simultáneos.

El counter mode genera bloques de keystream incrementando un contador de 32 bits añadido al IV de 96 bits. Cada bloque de keystream se XORea con el bloque de texto plano correspondiente. Estructuralmente es un stream cipher, por lo que no hay requisito de alineación de bloques ni padding.

GHASH autentica el ciphertext computando un hash polinómico en GF(2^128), el campo de Galois con 2^128 elementos, definido sobre el polinomio irreducible:

x¹²⁸ + x⁷ + x² + x + 1

Una hash subkey H = AES_K(0), el cifrado de un bloque de ceros, sirve como el elemento de campo para la multiplicación. Cada bloque de ciphertext se acumula:

X₀ = 0
X₁ = (X₀ ⊕ C₁) · H
X₂ = (X₁ ⊕ C₂) · H
...
Xₘ = (Xₘ₋₁ ⊕ Cₘ) · H
tag = (Xₘ ⊕ len_block) · H ⊕ ENC(K, IV || 0)

El tag final de 128 bits se almacena junto al ciphertext. Al descifrar, el tag se recomputa y se compara. Cualquier modificación al ciphertext, al IV o a los datos autenticados adicionales produce un tag diferente. El descifrado falla antes de devolver texto plano.

Una restricción dura: el IV debe ser único para cada cifrado bajo la misma clave. Si el mismo par de clave e IV se usa dos veces, un atacante puede XORear los dos ciphertexts y cancelar el keystream, recuperando una relación entre los dos textos planos. Se debe generar un IV criptográficamente aleatorio para cada operación de cifrado.

Flujos de cifrado y descifrado

Secuencia de cifrado y descifrado de la seed

La ruta de descifrado marca la Device Key desenvuelta como non-extractable dentro de la Web Crypto API. El material de claves dentro de SubtleCrypto es opaco para JavaScript. La clave puede usarse para operaciones pero no puede ser leída por ningún script, incluyendo uno inyectado vía XSS. El buffer del mnemónico se pone a cero en un bloque finally inmediatamente después de que la operación de firma se completa.

Lo que las matemáticas dicen honestamente

El esquema es sólido dentro de sus restricciones. El eslabón más débil no es la criptografía, es la entropía del PIN, y ningún conteo de iteraciones compensa completamente 20 bits de entrada.

A 600.000 iteraciones de PBKDF2, un atacante con CPU necesita días. Un atacante con GPU necesita horas. La mitigación que realmente resuelve esto es WebAuthn-bound key storage: usar un platform authenticator como compuerta del KDF. La credencial vive en un enclave seguro de hardware (p. ej. Secure Enclave), la clave privada nunca sale del hardware, y aplicar fuerza bruta a un factor biométrico no es un ataque viable. El soporte en navegadores y plataformas es inconsistente. La superficie de implementación es real.

El techo es el PIN. Eso vale la pena hacerlo explícito en vez de esconderlo detrás de elecciones de parámetros.

Hasta pronto.