Un PIN de 4 dígitos basta: claves vinculadas al ESP32-S3
Una RTX 5090 barre los 10.000 PIN de cuatro dígitos en una fracción de segundo offline, incluso con el número de iteraciones de producción, así que la defensa es un secreto por chip mezclado con HKDF, no más iteraciones.

Un PIN de cuatro dígitos tiene diez mil valores posibles. No es una errata ni un marcador de posición para “ya pondremos uno más largo luego”: es el secreto real que desbloquea la bóveda de un gestor de contraseñas hardware que estoy construyendo (aún sin publicar), un dispositivo de clase ESP32 que guarda tus credenciales en su flash interna. Diez mil. Una sola GPU moderna recorre diez mil de cualquier cosa antes de que termines de leer esta frase.
Entonces, ¿cómo es eso seguro? La respuesta honesta es que en un microcontrolador normalmente no lo es, y el arreglo habitual (subir el número de iteraciones de derivación de clave hasta que la fuerza bruta “tarde demasiado”) falla en silencio en este tipo de silicio. Este artículo va sobre el arreglo que sí funciona: vincular la clave al chip físico para que cada intento offline necesite un secreto que el atacante no puede extraer, mientras una pequeña protección en el dispositivo se encarga de los intentos que alguien hace a mano.
El “vale” del título es una afirmación sobre los builds endurecidos (_secure). Un build abierto, flasheable por desarrolladores, solo tiene el bloqueo en el dispositivo, que no hace nada contra una copia de la flash, y en el ESP32-S3 la ruta endurecida sigue esperando un paso de aprovisionamiento que queme su clave eFuse: mientras no exista, un build endurecido del S3 no puede ni crear una bóveda (detalles más abajo).
TL;DR: por qué un PIN de 4 dígitos sobrevive a un volcado de flash
5 puntos clave- Un PIN de 4 dígitos son 10⁴ = 10.000 combinaciones. Una RTX 5090 ejecuta PBKDF2-HMAC-SHA256 a ~11,16 MH/s con 999 iteraciones; escalado a las 175.000 que usa el ESP32-S3, siguen siendo unos 63.700 intentos por segundo, así que barre cada PIN offline en unos 0,16 s.
- El número de iteraciones de PBKDF2 no te salva en un MCU: el conteo se dimensiona para un desbloqueo de ~2 segundos en el dispositivo, y sea cual sea el conteo que haga eso llevadero, una GPU lo hace muchísimo más rápido.
- Las dos amenazas son distintas. El adivinado online (alguien con el dispositivo en la mano) está acotado por una protección de lockout / borrado-tras-10. El adivinado offline (alguien que volcó la flash) necesita una defensa diferente.
- Esa defensa es la vinculación al dispositivo: tras PBKDF2, se mezcla un
deviceSecretpor-chip con HKDF (RFC 5869). En el ESP32-S3 el secreto es un eFuse-HMAC que la CPU no puede leer; en el ESP32 clásico es un pepper en NVS. - Una clave vinculada al dispositivo solo se reproduce en el chip original, así que un barrido offline por GPU de un espacio de 10.000 claves se convierte en un ataque offline imposible: una ganancia categórica, no lineal.
Glosario rápido (si no vienes de cripto)
- Entropía · espacio de claves: cuánto cuesta adivinar un secreto. Un PIN de 4 dígitos tiene 10.000 combinaciones (~13,3 bits): poquísima.
- Fuerza bruta: probar todas las combinaciones, una a una, hasta dar con la buena.
- Online vs offline: online = adivinar contra el propio dispositivo, que puede frenarte; offline = adivinar contra una copia robada, en tu equipo, sin freno ni límite de intentos.
- KDF · PBKDF2 · iteraciones (factor de trabajo): una función que convierte el PIN en una clave repitiendo un cálculo muchas veces, para que cada intento sea lento a propósito.
- GPU · hashrate (H/s): una tarjeta gráfica prueba millones de claves por segundo; “H/s” son intentos por segundo.
- Vinculación al dispositivo · secreto del dispositivo: mezclar en la clave un secreto único de ese chip, que no puede salir de él.
- eFuse: bits grabados a fuego en el silicio (una sola vez) que el chip puede usar pero que no se pueden leer de vuelta.
- HKDF: el método estándar y analizado para mezclar y derivar claves a partir de un secreto.
- pepper · NVS: el pepper es un secreto extra común a todo el dispositivo (primo del salt); la NVS es el almacén de clave-valor persistente del ESP32.
Un PIN de 4 dígitos son 13,3 bits: 10.000 combinaciones que una RTX 5090 barre en una fracción de segundo
Seamos directos con el espacio de claves. Cuatro dígitos decimales son 104, exactamente diez mil valores: aproximadamente 13,3 bits de entropía (la jerga para “cuánto cuesta adivinarlo”) si cada PIN fuera igual de probable, y no lo son, porque los humanos eligen 1234, 0000 y su año de nacimiento mucho más a menudo de lo que sugeriría el azar. Un PIN de 4 dígitos es, en el fondo, un candado de bici de 10.000 posiciones: de sobra contra alguien que prueba a mano, ridículo ante una máquina que las recorre todas en un suspiro. No hay KDF ingenioso, ni cifrador, ni cantidad de salt que añada entropía a un secreto tan pequeño. El secreto es el secreto.
La razón de que un PIN tan corto sea siquiera defendible es que adivinarlo cuesta algo, y el coste depende por completo de dónde se le permite adivinar al atacante.
Cada PIN probado en unos 0,16 s: offline, el PIN no protege nada por sí solo.
| Magnitud | Valor |
|---|---|
| espacio de claves de Espacio de claves del PIN de 4 dígitos | 10.000 |
| 1× RTX 5090, PBKDF2-HMAC-SHA256 con 175.000 iteraciones | 63.700 intentos/s |
| Veredicto | Cada PIN probado en unos 0,16 s: offline, el PIN no protege nada por sí solo. |
Esa cifra de 11,16 MH/s no es hipotética. Sale directamente de un benchmark publicado de hashcat para la RTX 5090, donde PBKDF2-HMAC-SHA256 (hashcat modo 10900) corre a unos 11.157 kH/s en una sola tarjeta, y SHA-256 en crudo a unos 28,35 GH/s (modo 1400). Esa cifra de PBKDF2 se mide con el recuento del benchmark de hashcat, 999 iteraciones, y el coste de un intento crece linealmente con el recuento, así que con las 175.000 iteraciones que usa el ESP32-S3 la misma tarjeta hace 11_157_000 × 999 / 175_000 ≈ 63_700 intentos por segundo. Diez mil PINs son 10000 / 63_700 ≈ 0,16 segundos; con las 40.000 del ESP32 clásico, unos 0,04 segundos. El PIN, tratado como un secreto atacable offline, ya está perdido.
La diferencia entre online y offline lo es todo aquí. Online es el ladrón plantado en tu puerta probando llaves: puedes cerrarle de un portazo tras unos pocos intentos. Offline es el ladrón que se ha llevado una copia de la cerradura a su taller, donde lima llaves sin que nadie le vigile y con todo el tiempo del mundo. Así que lo único que hace viable un PIN corto es asegurarse de que el atacante no pueda pasar a offline, y acotar lo rápido que puede adivinar online.
¿Online u offline: de qué atacante te defiendes?
~10 intentos, y luego se borra la bóveda
- Introducir PINs en el teclado del dispositivo
- Apagar y volver a encender para intentarlo de nuevo
- Saltarse el contador de intentos persistente
- Adelantar al temporizador de lockout respaldado por el RTC
Lo detiene Protección contra fuerza bruta (lockout y luego borrado al 10)
≈ 63.700 PINs/s con 175k iteraciones: los 10⁴ en ≈ 0,16 s
- Leer el texto cifrado y el salt del KDF
- Ejecutar intentos ilimitados de PBKDF2 en una GPU
- Reproducir el secreto del dispositivo por-chip fuera del chip
Lo detiene Derivación de clave vinculada al dispositivo (este artículo)
| Atacante | Ritmo | Puede | No puede | Lo detiene |
|---|---|---|---|---|
| Atacante online (Tiene el dispositivo, teclea PINs) | ~10 intentos, y luego se borra la bóveda | Introducir PINs en el teclado del dispositivo; Apagar y volver a encender para intentarlo de nuevo | Saltarse el contador de intentos persistente; Adelantar al temporizador de lockout respaldado por el RTC | Protección contra fuerza bruta (lockout y luego borrado al 10) |
| Atacante offline (Volcó la flash, usa una GPU) | ≈ 63.700 PINs/s con 175k iteraciones: los 10⁴ en ≈ 0,16 s | Leer el texto cifrado y el salt del KDF; Ejecutar intentos ilimitados de PBKDF2 en una GPU | Reproducir el secreto del dispositivo por-chip fuera del chip | Derivación de clave vinculada al dispositivo (este artículo) |
El techo online: lockout y luego borrado
El lado online es la mitad fácil, y es la que la mayoría de productos resuelve bien. Mi firmware lleva la cuenta de los intentos fallidos consecutivos en NVS para que el conteo sobreviva a un reinicio o a un ciclo de deep-sleep, y escala: nada en los tres primeros intentos, un lockout de 30 segundos para los intentos del cuatro al seis, un lockout de 300 segundos del siete al nueve, y un borrado completo de la bóveda al décimo. La fecha límite del lockout se guarda contra el reloj de tiempo real, así que quitar la corriente no resetea el temporizador.
static constexpr uint8_t kShortLockStart = 4; // first timed lockout
static constexpr uint8_t kLongLockStart = 7; // longer lockout
static constexpr uint8_t kWipeThreshold = 10; // destroy the vault
static constexpr uint32_t kShortLockSec = 30;
static constexpr uint32_t kLongLockSec = 300;
uint32_t BruteForceGuard::registerFailure() {
NvsStore prefs;
prefs.open(kNvsNs, NvsStore::OpenMode::ReadWrite);
uint8_t attempts = prefs.getU8(kNvsAttempts, 0) + 1;
prefs.setU8(kNvsAttempts, attempts);
if (attempts >= kWipeThreshold) {
return UINT32_MAX; // caller MUST wipe the vault
}
uint32_t lockoutSec = getLockoutDuration(attempts);
// ... persist an RTC-relative unlock time so it survives power loss ...
return lockoutSec;
}Esta es la defensa canónica para un secreto corto, y es lo que quiere decir NIST SP 800-63B cuando te dice que limites la tasa del adivinado online. Su revisión 4 topa los intentos fallidos consecutivos en 100 deshabilitando el autenticador, y añade una cláusula más estricta dirigida justo a secretos como un PIN: cuando el secreto de autenticación lleva menos de 64 bits de entropía, el verificador deberá limitar la tasa del número total de intentos fallidos consecutivos. Un PIN de 13,3 bits cae de lleno dentro de esa cláusula. Para un atacante que teclea físicamente en el teclado, un PIN de 10.000 valores tras un “tienes unos diez intentos, y luego borro todo” es genuinamente fuerte. Diez intentos de diez mil son un 0,1 % de probabilidad, y solo tienes una pasada.
¿Por qué no puedes ganarle a una GPU a base de iteraciones en un MCU?
La respuesta de manual a “mi secreto tiene poca entropía” es un KDF basado en contraseña con un factor de trabajo alto. PBKDF2 (RFC 8018, el PKCS#5 actual; NIST SP 800-132 para la derivación de clave basada en contraseña) te deja elegir un número de iteraciones, y la OWASP Password Storage Cheat Sheet recomienda 600.000 iteraciones de PBKDF2-HMAC-SHA256. Haz cada intento lo bastante caro y hasta un espacio de claves pequeño se vuelve molesto de barrer. Esa es la teoría.
Se rompe en un microcontrolador por una razón física sencilla: el factor de trabajo ralentiza al dispositivo legítimo mucho más de lo que ralentiza al atacante. El número de iteraciones tiene que ser lo bastante bajo como para que el desbloqueo del usuario sea llevadero, y en este silicio ese techo es bajo. Mis builds de producción usan 175.000 iteraciones en el ESP32-S3 y 40.000 en el ESP32 clásico, cada uno dimensionado para un desbloqueo de unos 2 segundos (medido: unos 1,55 s de derivación de clave en el S3 y 1,6 s en el chip clásico, ambos a 240 MHz). El S3 se permite el recuento mayor porque su derivación maneja directamente el periférico SHA, a unos 8,8 µs por iteración; el ESP32 clásico ejecuta un SHA-256 software propio a unos 39,4 µs. Las 600.000 recomendadas por OWASP empujarían un solo desbloqueo a unos 5 segundos en el S3 y unos 24 segundos en el ESP32 clásico. Cinco segundos es mucha espera en un dispositivo que desbloqueas muchas veces al día, y casi medio minuto en el chip clásico es inutilizable, no solo incómodo. Por eso la hoja de ruta de Kleidos incluye las 600.000 iteraciones entre las opciones descartadas.
Lo que de verdad le cuesta al usuario subir las iteraciones
| Métrica | Antes | Después | Cambio |
|---|---|---|---|
| ESP32-S3 | 2 s | 5,3 s | +165% |
| ESP32 clásico | 2 s | 24 s | +1100% |
Cada fila es un solo desbloqueo con el conteo de producción (175.000 en el S3, 40.000 en el ESP32 clásico) frente a las 600.000 de OWASP, que son 3,4 veces las iteraciones en el S3 y 15 en el ESP32 clásico. El usuario lo paga entero; el atacante apenas lo nota.
El gráfico muestra el único mando que te da el número de iteraciones: hacer esperar más al usuario. Subir el S3 de 175.000 a 600.000 convierte un desbloqueo de 2 segundos en uno de 5 (el ESP32 clásico sale mucho peor parado, unos 24 segundos). Mientras tanto, el lado del atacante en el balance apenas se mueve: escalada a 600.000 iteraciones, una sola RTX 5090 sigue moliendo unos 18.600 intentos por segundo, y un espacio de 10.000 claves se despeja en medio segundo. El MCU hace una derivación en un segundo y medio; con el mismo recuento, la GPU hace decenas de miles cada segundo. Estás compitiendo contra un velocista mientras los lastres en los tobillos te los pones tú.
¿Qué es la vinculación al dispositivo en la derivación de clave?
La vinculación al dispositivo mezcla un deviceSecret de 32 bytes por chip en la clave maestra después de PBKDF2, usando HKDF. El verificador almacenado en flash solo se reproduce entonces en el chip original, así que un atacante que vuelque esa flash sigue sin poder recalcular la clave para ningún intento de PIN.
En la bóveda de hoy esa salida de PBKDF2 es la clave de cifrado de clave que desenvuelve la clave de bóveda (el artículo anterior cuenta el envoltorio); el código de vinculación la llama clave maestra, y este artículo también.
El razonamiento es simple: si no puedes hacer cada intento offline lo bastante lento, haz que cada intento offline necesite algo que el atacante no tiene. Después de que PBKDF2 produce la clave maestra a partir del PIN y el salt, mezclo un deviceSecret de 32 bytes que solo el chip original puede producir. El verificador almacenado en flash se deriva entonces de esa clave maestra vinculada. Un atacante que vuelca la flash tiene el texto cifrado y el salt, pero recalcular la clave para cualquier intento de PIN exige ahora el secreto por-chip. En el ESP32-S3 ese secreto nunca aparece en la flash que copió: vive en un eFuse de solo lectura y el periférico HMAC lo usa sin exponerlo. En el ESP32 clásico, que no tiene ese periférico, el respaldo es un pepper guardado en NVS cifrada: sigue costándole trabajo al atacante, pero está en el chip y no fuera de su alcance, así que la garantía es más débil.
Es como pedir, además del PIN, un ingrediente que solo existe en este chip concreto y que no se puede copiar: por muy perfecta que sea la copia de la flash que se lleve el ladrón, sin ese ingrediente sus 10.000 intentos no van a ninguna parte. Este es todo el truco, y cambia el ataque de lineal a categórico. Antes de la vinculación, el atacante offline necesita 10.000 evaluaciones baratas de PBKDF2 y gana. Después de la vinculación, necesita un secreto que físicamente no sale del chip, así que el ataque offline no se vuelve más lento: deja de existir. El único sitio donde se puede comprobar un intento es de vuelta en el dispositivo, donde la protección de lockout-y-borrado está esperando.
Dónde encaja la vinculación en la cadena del KDF
- PIN + kdfSalt secreto de usuario + 16 bytes aleatorios
- PBKDF2-SHA256 175k iters en S3 (Tier 1)
- clave maestra 32 bytes
- bindMasterKey() HKDF + deviceSecret (Tier 2)
- pinVerifier HMAC, comparación en tiempo constante
Crear la bóveda y desbloquearla ejecutan esta misma cadena; el paso de vinculación es idéntico en ambas, así que el verificador solo se reproduce en el mismo chip.
En el firmware esto es una sola función, llamada tanto por la ruta que crea la bóveda como por la que la desbloquea, para que nunca puedan divergir:
bool deriveKek(const char* bytes, size_t len, const std::array<uint8_t, kKdfSaltSize>& salt,
uint32_t iterations, std::array<uint8_t, kMasterKeySize>& kekOut) {
// Tier 1: stretch the secret, at the count THIS vault recorded.
if (!VaultCrypto::deriveKey(bytes, len, salt.data(), salt.size(),
kekOut.data(), kekOut.size(), iterations)) {
VaultCrypto::secureWipe(kekOut.data(), kekOut.size());
return false;
}
// Tier 2: bind it to THIS device (a no-op on open builds). Identical in the
// mint and in the unlock, or the verifier never reproduces.
if (!kleidos::crypto::bindMasterKey(salt.data(), salt.size(),
kekOut.data(), kekOut.size())) {
VaultCrypto::secureWipe(kekOut.data(), kekOut.size());
return false;
}
return true;
}
// The unlock then derives the PIN verifier from the bound key and compares it,
// in constant time, against the one stored in meta.bin.La vinculación es una característica de producción endurecida, condicionada tras un build flag. Los builds de desarrollo abiertos mantienen su ergonomía de reflasheo sin quemar eFuse, así que en ellos el paso de vinculación es un no-op byte a byte: la clave derivada es exactamente lo que produjo PBKDF2. Es deliberado, y volveré al final a lo que te cuesta.
¿De dónde sale el secreto del dispositivo por-chip en el ESP32?
El “secreto que solo este chip puede producir” tiene dos implementaciones, elegidas en tiempo de compilación según lo que el chip realmente sabe hacer. La capacidad que decide es si el SoC tiene un periférico HMAC por hardware, que ESP-IDF expone como SOC_HMAC_SUPPORTED. El ESP32-S3 lo tiene; el ESP32 clásico no. Ese único hecho es la razón de que el código lleve dos backends en vez de uno.
| Backend | Se selecciona cuando | El secreto del dispositivo es… | Fortaleza |
|---|---|---|---|
EFuseHmac | Build endurecido + clase ESP32-S3 (tiene periférico HMAC) | HMAC_eFuse(label ‖ kdfSalt) sobre una clave en eFuse ilegible por software/JTAG | El más fuerte: la clave nunca sale del hardware |
NvsPepper | Build endurecido + ESP32 clásico (sin periférico HMAC) | Un pepper aleatorio de 32 bytes generado una vez, almacenado en NVS cifrado | Más débil: alcanzable por software, pero frena la exfiltración ingenua de flash |
None | Builds abiertos / sin capacidad | Identidad: la clave maestra se deja sin cambios | Sin vinculación (builds de dev reflasheables) |
La selección es una cascada en tiempo de compilación. La ruta EFuseHmac solo compila donde ESP-IDF habilita su driver HMAC opaco de PSA, y ESP-IDF condiciona eso a SOC_HMAC_SUPPORTED, así que la misma macro que significa “este chip tiene el periférico HMAC” sirve también como condición para elegir el backend fuerte. Sin capacidad y con un build endurecido, se cae al pepper; un build abierto resuelve a None.
eFuse-HMAC: una clave que la CPU no puede leer
Piensa en una caja negra sellada con un secreto dentro: puedes meterle un mensaje y te devuelve una respuesta calculada con ese secreto, pero no hay manera de abrirla y leer el secreto. Eso es, casi al pie de la letra, el periférico HMAC del ESP32-S3. El secreto del dispositivo lo computa ese periférico HMAC, y la propiedad que importa está ahí mismo en la documentación de Espressif: en modo “upstream”, el periférico computa un HMAC sobre un mensaje usando una clave quemada en eFuse, y la clave eFuse nunca sale del módulo: el resultado HMAC se devuelve al software por diseño, pero los bytes de la clave no. El bloque de clave se quema con el propósito ESP_EFUSE_KEY_PURPOSE_HMAC_UP (valor 8) y queda protegido contra lectura, así que el software y JTAG simplemente no pueden volver a leerlo. Le pasas un mensaje, recibes un tag de 32 bytes, y la clave sigue sellada.
eFuse-HMAC (ESP32-S3)
El secreto nunca llega al software
Los bytes de la clave nunca salen del periférico HMAC (ilegibles por software o JTAG). Solo vuelve el tag.
Pepper en NVS (ESP32 clásico)
El secreto es accesible por software
La CPU lee el pepper para usarlo; su confidencialidad se apoya en el cifrado de flash + NVS.
| Backend | Secreto del dispositivo | Cruza al software | ¿Accesible por software? |
|---|---|---|---|
| eFuse-HMAC (ESP32-S3) | bloque de clave eFuse (quemado HMAC_UP (8), protegido contra lectura) | resultado HMAC-SHA256 (32 B) | No |
| Pepper en NVS (ESP32 clásico) | pepper de 32 bytes (almacenado en NVS cifrado) | el pepper mismo | Sí |
En el S3 la clave eFuse nunca cruza al software; solo lo hace el resultado HMAC. En el ESP32 clásico, lo que la CPU recupera es el secreto mismo.
Dirigir el periférico HMAC a través de PSA Crypto
ESP-IDF 6.x maneja el periférico HMAC a través de la interfaz de clave opaca de PSA Crypto en vez de la antigua llamada esp_hmac_calculate, y ese es justo el camino que toma el firmware. El truco es un psa_import_key de una estructura diminuta que contiene el key id del eFuse (una referencia al bloque de clave, no bytes de clave) bajo el lifetime PSA_KEY_LIFETIME_ESP_HMAC_VOLATILE. PSA entonces enruta psa_mac_compute hacia el periférico, que hace el HMAC sobre mi mensaje internamente y devuelve solo el resultado.
// Message = domain label || vaultSalt, so the secret is specific to this
// use and per-vault.
std::memcpy(message.data(), kEFuseLabel, kLabelLen); // "kleidos-device-secret-v1"
std::memcpy(message.data() + kLabelLen, vaultSalt, vaultSaltLen);
// Import a REFERENCE to the eFuse key block — never the key bytes.
esp_hmac_opaque_key_t opaque = {};
opaque.efuse_key_id = kHmacEfuseKeyId; // HMAC_KEY0 by default
// PSA attributes (elided): an opaque, volatile HMAC-SHA256 key.
psa_import_key(&attr, /* reference to opaque */ ..., sizeof(opaque), &keyId);
// Runs entirely inside the HMAC peripheral; out[] receives the 32-byte tag.
psa_mac_compute(keyId, PSA_ALG_HMAC(PSA_ALG_SHA_256),
message.data(), messageLen, out.data(), out.size(), &macLen);
psa_destroy_key(keyId);
VaultCrypto::secureWipe(message.data(), message.size());Pepper en NVS: el fallback del ESP32 clásico
El ESP32 clásico no tiene periférico HMAC, así que la ruta fuerte sencillamente no está disponible: no hay hardware que retenga una clave y se niegue a devolverla. El fallback es una idea vieja y bien entendida: un pepper. En el primer aprovisionamiento, el firmware genera un valor aleatorio de 32 bytes, lo almacena una vez, y lo recarga en cada desbloqueo a partir de entonces.
bool DeviceSecret::computePepper(PepperStore& store,
std::array<uint8_t, kDeviceSecretSize>& out) {
if (store.load(out)) {
return true; // reload the existing pepper
}
// First provision on this device: generate and persist a fresh pepper.
VaultCrypto::generateRandom(out.data(), out.size());
if (!store.store(out)) {
VaultCrypto::secureWipe(out.data(), out.size());
return false;
}
return true;
}El planteamiento honesto es que esto es más débil que la ruta eFuse, y el diagrama de arriba dice por qué: la CPU tiene que leer el pepper para usarlo, así que es alcanzable por software de un modo en que la clave eFuse nunca lo es. Su confidencialidad descansa por completo en que el cifrado de flash + cifrado de NVS estén activos en los builds endurecidos, y el pepper nunca se registra en logs. Lo que sí te compra es real: una exfiltración ingenua de flash (desoldar el chip, volcarlo, atacar la copia en una GPU) ya no funciona, porque el pepper vive en NVS cifrado y no está presente en un volcado en claro. Es una barrera con sentido frente al ataque offline más común, solo que no el sello hardware categórico que recibe el S3.
¿Por qué usar HKDF en lugar de un HMAC hecho a mano?
HKDF (RFC 5869) es un KDF de extract-then-expand publicado y analizado que convierte un secreto no uniforme en material de clave uniforme con separación de dominio, así que la construcción se mantiene estándar y citable en vez de casera. Se apoya en un único primitivo HMAC, que es lo que mantiene la salida idéntica byte a byte en host y dispositivo.
Una vez que tienes un secreto del dispositivo de 32 bytes y una clave maestra de PBKDF2 de 32 bytes, hay que combinarlos en una clave nueva. La tentación es echar mano de un HMAC(deviceSecret, master) pelado y darlo por hecho. Yo no lo hice, porque existe un primitivo publicado y analizado construido justo para esto (la herramienta homologada en lugar de mezclar a ojo): HKDF (RFC 5869), el KDF de extract-then-expand cuyo argumento de seguridad expuso Hugo Krawczyk en Cryptographic Extraction and Key Derivation: The HKDF Scheme. HKDF te convierte un secreto no uniforme en material de clave uniforme con separación de dominio, y usarlo tal cual mantiene toda la construcción como algo citable y estándar en lugar de algo que me inventé un martes.
La construcción es la canónica de dos pasos:
- salt deviceSecret IKM PBKDF2 masterHKDF-ExtractPRK clave pseudoaleatoria
- del extract PRK info "kleidos-device-bind-v1"HKDF-Expandclave maestra vinculada 32 bytes
HKDF vincula la clave en dos pasos: extraer un PRK con clave del secreto del dispositivo, luego expandirlo bajo una etiqueta versionada hasta la clave maestra vinculada.
| Operación | Entradas | Salida |
|---|---|---|
| HKDF-Extract | deviceSecret (salt) + PBKDF2 master (IKM) | PRK |
| HKDF-Expand | PRK (del extract) + "kleidos-device-bind-v1" (info) | clave maestra vinculada |
El salt es el secreto del dispositivo y el material de clave de entrada es la clave maestra de PBKDF2, así que PRK = HKDF-Extract(salt = deviceSecret, IKM = master) y luego boundMaster = HKDF-Expand(PRK, info = "kleidos-device-bind-v1", L = 32). La cadena info versionada es separación de dominio: si alguna vez cambio la construcción de la vinculación, un info distinto hace que las claves nuevas sean demostrablemente distintas de las viejas.
Un único primitivo HMAC, idéntico byte a byte en host y dispositivo
Aquí va el detalle de ingeniería del que estoy más orgulloso. Mi HKDF está construido sobre el propio VaultCrypto::hmacSha256 del firmware, no sobre el módulo HKDF de una librería; eso es una característica, no reinvención. Mbed TLS 4.0 eliminó el módulo legacy mbedtls_hkdf (se espera que manejes psa_key_derivation_* con PSA_ALG_HKDF en su lugar), y cuando se escribió esto los tests nativos del host aún corrían un Mbed TLS legacy, así que ningún HKDF de librería habría compilado en los dos sitios. Construir HKDF sobre un único primitivo HMAC esquivó por completo el cambio de API de 3.6 a 4.0. Desde entonces los tests del host usan también Mbed TLS 4 y TF-PSA-Crypto, una versión de parche por detrás del dispositivo, así que ese HMAC es ahora un único backend PSA en los dos lados, y ambos lo contrastan con el mismo vector publicado (el caso de prueba 1 de RFC 4231): la salida es idéntica byte a byte en todas partes.
// Extract: PRK = HMAC(salt, IKM). RFC 5869 uses a HashLen zero salt for the
// empty-salt case; we fall back to that since an empty HMAC key isn't portable.
std::array<uint8_t, kHashLen> prk{};
VaultCrypto::hmacSha256(hmacSalt, hmacSaltLen, ikm, ikmLen, prk.data());
// Expand: T(0) = empty; T(n) = HMAC(PRK, T(n-1) || info || counter); OKM = T(1)..
for (uint32_t counter = 1; ok && done < outLen; ++counter) {
/* block = T(n-1) || info || counter */
ok = VaultCrypto::hmacSha256(prk.data(), prk.size(), block.data(), off, tCur.data());
const size_t take = (outLen - done < kHashLen) ? (outLen - done) : kHashLen;
std::memcpy(out + done, tCur.data(), take);
/* ... carry T(n) forward, wipe scratch ... */
}bindMasterKey() lo ata todo: para el backend None retorna de inmediato con la clave maestra intacta; en otro caso computa el secreto del dispositivo, ejecuta HKDF, sobrescribe la clave maestra in situ con el resultado vinculado, y limpia cada valor intermedio con secureWipe antes de retornar.
Demostrarlo: un known-answer test
Una vinculación que esté mal es peor que no tener vinculación: si provision() y unlock() discreparan en un solo byte, cada desbloqueo fallaría y la bóveda quedaría inutilizada. Así que la construcción está fijada por tests, y el que carga el peso es un known-answer test (comprobar que nuestra salida coincide, byte a byte, con unos números de referencia oficiales, como cotejar un resultado con la hoja de respuestas) contra los vectores publicados en el Apéndice A de la RFC 5869. Como el HKDF corre idéntico en el host y en el dispositivo, comprobarlo contra el vector de la RFC en el build nativo demuestra que el primitivo HKDF en sí es byte-exacto, así que la vinculación que computa se reproduce de forma idéntica en host y dispositivo.
// RFC 5869 Appendix A.1, Test Case 1 (HKDF-SHA256).
static constexpr std::array<uint8_t, 42> kRfcOkm = {
0x3c, 0xb2, 0x5f, 0x25, 0xfa, 0xac, 0xd5, 0x7a, 0x90, 0x43, 0x4f, 0x64, /* ... */ };
static void test_hkdf_rfc5869_case1_matches_known_answer() {
std::array<uint8_t, 42> okm{};
const bool ok = hkdfSha256({kRfcSalt.data(), kRfcSalt.size()},
{kRfcIkm.data(), kRfcIkm.size()},
{kRfcInfo.data(), kRfcInfo.size()},
okm.data(), okm.size());
TEST_ASSERT_TRUE(ok);
TEST_ASSERT_EQUAL_MEMORY(kRfcOkm.data(), okm.data(), okm.size()); // exact bytes
}El mismo archivo de test comprueba además las partes que no necesitan silicio: que la mezcla es determinista (el mismo chip y PIN siempre dan la misma clave vinculada), que la vinculación es un no-op byte a byte cuando está deshabilitado, y, mediante un PepperStore falso en memoria, que el pepper se genera exactamente una vez y se recarga a partir de entonces. Si quieres el panorama más amplio de cómo esa clave vinculada protege luego cada archivo en reposo, ese es el tema del artículo complementario sobre el sobre encrypt-then-MAC de la bóveda; esta clave es la que rellena ese sobre.
Los costes honestos
La vinculación al dispositivo no es gratis, y fingir lo contrario sería su propia forma de deshonestidad. Atar la clave al chip es precisamente lo que derrota al atacante offline, lo que significa que es también precisamente lo que pierdes si el chip muere.
- Un chip muerto o borrado es irrecuperable, por diseño. La bóveda está vinculada a este silicio. Si el chip falla o se dispara el borrado, la clave no puede reconstruirse en ningún otro sitio. La recuperación es la ruta de exportación/copia de seguridad que dejaste preparada de antemano, no custodia de claves.
- El quemado del eFuse es de una sola vez e irreversible. Aprovisionar el backend
EFuseHmacquema un bloque de clave eFuse con propósitoHMAC_UP, que consume ese bloque de forma permanente. Tiene que ser una decisión del propietario tomada a conciencia, no algo que un build flag haga a tus espaldas, y todavía no lo hace ningún paso (ver la nota de abajo). - Los builds abiertos no vinculan en absoluto. Los builds de desarrollo resuelven al backend
None, así que el paso de vinculación es un no-op byte a byte y el firmware sigue siendo reflasheable sin quemar eFuse. Cómodo para trastear con el dispositivo; también significa que un build abierto ofrece cero protección offline. - La ruta del pepper es totalmente testeable hoy: generar, persistir, recargar y la mezcla HKDF corren todos bajo el harness nativo del host contra el vector de la RFC.
Una nota a pie que te debo: la clave del ESP32-S3 aún no está aprovisionada
Cierre: un PIN corto, dos defensas complementarias
Da un paso atrás y el diseño es una división del trabajo limpia. Un PIN de 4 dígitos no tiene nada que hacer contra una GPU offline y es perfectamente válido contra alguien que teclea en un teclado, así que defiendo cada carril con el control que de verdad le encaja. El adivinado online se topa con la protección contra fuerza bruta: un puñado de intentos, lockouts que escalan y un borrado de la bóveda al décimo. El adivinado offline se topa con la vinculación al dispositivo: un secreto por-chip, sellado en el periférico HMAC en el S3 o guardado en NVS cifrado en el ESP32 clásico, mezclado en la clave con HKDF para que la clave maestra vinculada solo se reproduzca jamás en el silicio original. Los dos carriles tienen que estar presentes: eso es un build endurecido y, en el S3, una clave eFuse aprovisionada.
Ninguna defensa por sí sola basta. El lockout no significa nada si el atacante puede adivinar fuera del dispositivo; la vinculación no significa nada contra alguien que simplemente sigue tecleando PINs. Juntas, una flash volcada es solo texto cifrado y un salt para una clave que el atacante no puede recalcular, y un dispositivo robado está a diez intentos de quedar borrado. Así es como diez mil combinaciones se vuelven genuinamente suficientes.
Preguntas frecuentes
¿Cómo puede ser seguro un PIN de 4 dígitos en un gestor de contraseñas hardware?
Un PIN de 4 dígitos solo es seguro porque dos defensas complementarias acotan dónde puede adivinar un atacante. Una protección contra fuerza bruta online permite unos diez intentos antes de borrar la bóveda, y la derivación de clave vinculada al dispositivo mezcla un secreto por-chip en la clave para que un barrido offline por GPU del espacio de 10.000 PINs no pueda reproducirse fuera del chip original.
¿Por qué un número alto de iteraciones de PBKDF2 no protege un PIN en un microcontrolador?
El número de iteraciones debe ser lo bastante bajo como para que el desbloqueo del usuario legítimo sea llevadero, unos 2 segundos, lo que significa 175.000 iteraciones en el ESP32-S3 y 40.000 en el ESP32 clásico. Las 600.000 recomendadas por OWASP empujarían un solo desbloqueo a unos 5 segundos en el S3 y 24 en el ESP32 clásico, mientras una sola GPU sigue barriendo los 10.000 PINs en medio segundo con ese recuento.
¿Qué es la vinculación al dispositivo en la derivación de clave?
La vinculación al dispositivo mezcla un deviceSecret por-chip de 32 bytes en la clave que produce PBKDF2, usando HKDF. El verificador almacenado en flash solo se reproduce entonces en el chip original, así que un atacante que vuelque la flash sigue sin poder recalcular la clave sin el secreto, convirtiendo un ataque offline de fuerza bruta de lineal a categóricamente imposible.
¿De dónde sale el secreto del dispositivo por-chip en el ESP32?
Tiene dos backends elegidos en tiempo de compilación. En el ESP32-S3 es un eFuse-HMAC: el periférico HMAC computa un tag usando una clave en eFuse protegida contra lectura que nunca sale del silicio. En el ESP32 clásico, que no tiene periférico HMAC, es un pepper aleatorio de 32 bytes almacenado en NVS cifrado. Es más débil, pero aun así frena un volcado de flash ingenuo.
¿Por qué usar HKDF en lugar de un HMAC hecho a mano para mezclar el secreto del dispositivo?
HKDF (RFC 5869) es un KDF de extract-then-expand publicado y analizado que convierte un secreto no uniforme en material de clave uniforme con separación de dominio, manteniendo la construcción estándar y citable. Está construido sobre un único primitivo HMAC, así que la salida es byte a byte idéntica en host y dispositivo, y queda fijado por un known-answer test contra los vectores del Apéndice A de la RFC 5869.
¿Qué pasa con la bóveda si el dispositivo muere?
Un chip muerto o borrado es irrecuperable por diseño, porque la clave está vinculada a ese silicio concreto y no puede reconstruirse en otro sitio. La recuperación se apoya en una ruta de exportación/copia de seguridad preparada de antemano, no en custodia de claves. El quemado del eFuse es además de una sola vez e irreversible.
Lecturas y recursos adicionales
- un gestor de contraseñas hardware que estoy construyendo kleidos.dev
- benchmark publicado de hashcat para la RTX 5090 gist.github.com
- NIST SP 800-63B NIST
- KDF basado en contraseña IETF
- NIST SP 800-132 NIST
- OWASP Password Storage Cheat Sheet OWASP
- la hoja de ruta de Kleidos kleidos.dev
- el artículo anterior jmrp.io
- Un atacante que vuelca la flash kleidos.dev
- Los builds de desarrollo abiertos kleidos.dev
- periférico HMAC Espressif
- eFuse Espressif
- que ESP-IDF usa para derivar claves de cifrado de NVS Espressif
- pepper OWASP
- HKDF IETF
- Cryptographic Extraction and Key Derivation: The HKDF Scheme IACR ePrint
- Apéndice A de la RFC 5869 IETF
- sobre encrypt-then-MAC de la bóveda jmrp.io