Un PIN de 4 dígitos basta: claves vinculadas al ESP32-S3
Por qué las iteraciones de PBKDF2 no protegen un PIN de 4 dígitos en un MCU, y cómo un secreto del dispositivo más HKDF frenan la fuerza bruta offline.

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.
TL;DR — Por qué un PIN de 4 dígitos sobrevive a un volcado de flash
- Un PIN de 4 dígitos son 10⁴ = 10.000 combinaciones. Una RTX 5090 ejecuta PBKDF2-HMAC-SHA256 a ~11,16 MH/s, así que barre cada PIN offline en bastante menos de un milisegundo.
- 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 no tiene entropía
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 bastante menos de 1 ms — 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 | 11.160.000 intentos/s |
| Veredicto | Cada PIN probado en bastante menos de 1 ms — 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). A once millones de intentos por segundo, diez mil PINs son 10000 / 11_160_000 ≈ 0,0009 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.
~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)
11,16 MH/s — los 10⁴ PINs en < 1 ms
- 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) | 11,16 MH/s — los 10⁴ PINs en < 1 ms | 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 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.
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 35.000 iteraciones en el ESP32-S3 y 25.000 en el ESP32 clásico, cada uno dimensionado para un desbloqueo de unos 2 segundos. Las 600.000 recomendadas por OWASP empujarían un solo desbloqueo a unos 32 segundos en el S3 — y unos 47 segundos en el ESP32 clásico. Nadie va a esperar medio minuto para abrir su gestor de contraseñas, así que ese conteo es físicamente inutilizable como UX, no solo incómodo.
| Métrica | Antes | Después | Cambio |
|---|---|---|---|
| ESP32-S3 | 2 s | 32 s | +1500% |
| ESP32 clásico | 2 s | 47 s | +2250% |
Cada fila es un solo desbloqueo con el conteo de producción frente a las 600.000 de OWASP — más o menos un salto de 16× en el S3. 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 35.000 a 600.000 convierte un desbloqueo de 2 segundos en uno de 32 (el ESP32 clásico sale peor parado, unos 47 segundos). Mientras tanto, el lado del atacante en el balance apenas se mueve — incluso a 600.000 iteraciones, una sola RTX 5090 muele PBKDF2-HMAC-SHA256 a cientos de miles de intentos por segundo, y un espacio de 10.000 claves se despeja igual en una fracción de segundo. El MCU hace una derivación en dos segundos; la GPU hace cientos de miles en paralelo. Estás compitiendo contra un velocista mientras los lastres en los tobillos te los pones tú.
La idea: vincular la clave al silicio
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 — y ese secreto nunca aparece en la flash que copió.
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.
- 1 PIN + kdfSalt secreto de usuario + 16 bytes aleatorios
- 2 PBKDF2-SHA256 35k iters (Tier 1)
- 3 clave maestra 32 bytes
- 4 bindMasterKey() HKDF + deviceSecret (Tier 2)
- 5 pinVerifier HMAC, comparación en tiempo constante
provision() y unlock() 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 llamada, insertada tanto en la ruta de aprovisionamiento como en la de desbloqueo para que nunca puedan divergir:
// Tier 1: stretch the PIN into a master key.
VaultCrypto::deriveKey(pin, pinLen, meta.kdfSalt.data(), meta.kdfSalt.size(),
masterKey.data(), masterKey.size(), iterations);
// Tier 2: bind the master key to THIS device (a no-op on open builds).
// Must mirror provision() exactly so the verifier reproduces on the same chip.
crypto::bindMasterKey(meta.kdfSalt.data(), meta.kdfSalt.size(),
masterKey.data(), masterKey.size());
// Derive the verifier from the (now bound) master key and compare constant-time.
VaultKeys::derivePinVerifier(masterKey, candidate);
const bool match = VaultCrypto::constantTimeEqual(
candidate.data(), meta.pinVerifier.data(), kPinVerifierSize);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
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 — y 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.
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); // "vault-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.
Mezclarlo con HKDF, no con un HMAC hecho a mano
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 "vault-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) + "vault-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 = "vault-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.
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 — y eso es una característica, no reinvención. El dispositivo corre PSA Crypto sobre mbedTLS 4.0; los tests nativos en el host corren un mbedTLS legacy. Lo crucial: mbedTLS 4.0 eliminó el módulo legacy mbedtls_hkdf (se espera que manejes psa_key_derivation_* con PSA_ALG_HKDF en su lugar), así que un HKDF portable que llame al módulo viejo simplemente no compilará en el dispositivo, y uno que llame solo a la ruta PSA no correrá en el host. Al construir HKDF sobre un único primitivo HMAC que existe en ambos backends, obtengo salida byte a byte idéntica en todas partes y esquivo por completo el cambio de API de 3.6 a 4.0.
// 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. Es una decisión del propietario tomada a conciencia en el primer arranque endurecido, no algo que un build flag deba hacer a tus espaldas. - 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.
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.
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 35.000 iteraciones en el ESP32-S3 y 25.000 en el ESP32 clásico. Las 600.000 recomendadas por OWASP empujarían un solo desbloqueo a unos 32 segundos en el S3 y 47 en el ESP32 clásico, mientras una GPU sigue barriendo los 10.000 PINs en menos de un milisegundo.
¿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 maestra tras 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 — 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.