Volver al Blog
51 min Copiar como Markdown
Parte 2 / 3 · Firmware de Kleidos: tres decisiones bajo restricciones duras

Una bóveda con bloqueo ante fallos: Encrypt-then-MAC en un MCU

El tag se verifica antes de descifrar un solo byte, así que un fichero reubicado, retipado o revertido falla el MAC, incluso para quien tenga la flash en crudo pero no el PIN.

Imagen de portada de Una bóveda con bloqueo ante fallos: Encrypt-then-MAC en un MCU

Una bóveda de contraseñas tiene un único trabajo en reposo: devolverte exactamente los bytes que guardaste, o negarse. No “probablemente”, no “lo intento”: una bóveda que devuelve casi tu contraseña, o que se comporta de forma distinta cuando un fichero ha sido manipulado, es peor que no tener bóveda. En un microcontrolador, donde los ficheros cifrados viven en un chip de flash que un atacante puede desoldar y leer, “negarse” tiene que ser el valor por defecto para todo lo que no sea demostrablemente auténtico.

Este artículo trata del formato de fichero que uso en un gestor de contraseñas hardware que estoy construyendo (aún sin publicar), un dispositivo de la clase ESP32 que almacena credenciales en su flash interna. Todo el diseño descansa sobre una regla que es sorprendentemente fácil de equivocar: nunca descifres un byte que no hayas autenticado primero. Equivoca el orden y habrás construido un oráculo de padding. Acierta con él y la bóveda se bloquea ante el fallo (un fail-closed).

TL;DR: qué hace este formato

6 puntos clave
  • Cada fichero de la bóveda es un sobre encrypt-then-MAC: [ver][iv][hmacTag][cipher]. El tag autentica un prefijo de contexto (ver ‖ recordType ‖ slotId ‖ generation) por delante del IV y del texto cifrado.
  • Al leer, el tag se recalcula y compara en tiempo constante antes de cualquier descifrado: verificar antes de descifrar.
  • Vincular slot, tipo y un contador de frescura hace que un fichero reubicado, retipado o revertido (rollback) falle el MAC, incluso frente a un atacante con acceso en bruto a la flash pero sin PIN.
  • Los contadores de frescura viven en meta.bin, que está autenticado a su vez; las mutaciones son resistentes a cortes de corriente (append → prove → commit → index), con el renombrado de meta.bin como único punto de commit.
  • El PIN desbloquea una clave de bóveda aleatoria envuelta dentro de meta.bin, así que cambiar el PIN reenvuelve un solo valor en vez de recifrar todos los registros. El precio: cambiar el PIN ya no revoca una clave que alguien haya capturado.
  • El cifrado y la autenticación usan claves separadas; cada camino de rechazo no produce ningún texto plano y borra su scratch.
Glosario rápido (si no vienes de cripto)
  • Texto plano · texto cifrado: el dato legible (tu contraseña) y su versión revuelta e ilegible una vez cifrada.
  • Cifrar · clave: revolver un dato con una clave (un secreto), de forma que solo quien tenga la clave pueda recuperar el original. Aquí es tu PIN el que desbloquea la clave.
  • Cifrador de bloque (AES) · padding: AES cifra en trozos fijos de 16 bytes; el padding son los bytes de relleno que completan el último trozo.
  • MAC · HMAC · tag: un sello a prueba de manipulación. El HMAC calcula, con una clave, un tag (una huella corta); si alguien cambia un solo byte, el tag deja de cuadrar.
  • IV (vector de inicialización): bytes aleatorios, uno nuevo por escritura, que hacen que cifrar dos veces el mismo dato dé resultados distintos.
  • KDF · PBKDF2: una función que “estira” un secreto débil (un PIN) hasta convertirlo en una clave, lenta a propósito para fastidiar a quien adivina.
  • Fuerza bruta offline: probar todas las combinaciones en tu propio hardware, con una copia robada de los datos y sin límite de intentos.
  • slot: cada hueco numerado donde la bóveda guarda una credencial.

Descifrar primero es pegarse un tiro en el pie

Primero, la amenaza. Es un dispositivo que cabe en la mano, y las credenciales viven en un chip de flash soldado a la placa. Un atacante que lo roba no se limita a teclear PINs en la pantalla: puede desoldar la flash y leerla en un programador de banco, o volcarla por un puerto de depuración, y marcharse con el texto cifrado en bruto. A partir de ahí el ataque es offline: sin bloqueo, sin límite de tasa, con todo el tiempo del mundo. El modelo de amenaza del firmware trata el volcado en bruto de la flash como una posibilidad real (mitigada en producción por la propia flash encryption del ESP32), así que el formato de fichero tiene que asumir que el atacante tiene en la mano exactamente los bytes que escribió y es libre de modificarlos y devolvérselos al sistema.

Eso es lo que hace que descifrar primero sea peligroso. El diseño intuitivo es el equivocado: tienes texto cifrado, quieres texto plano, así que descifras y solo entonces, quizá, compruebas si el resultado “tiene buena pinta”. Para ver por qué ese orden es fatal, hay que asomarse a lo que ocurre por dentro.

¿Cómo se convierte el padding de CBC en un oráculo?

Empecemos por lo básico: AES es un cifrador de bloque, y eso significa que solo sabe trabajar con trozos de tamaño fijo (16 bytes cada uno). Como tus datos casi nunca miden un múltiplo exacto de 16, el último trozo se completa con bytes de relleno (el padding) hasta cuadrar el bloque. El esquema PKCS#7 lo hace con una regla simple: si faltan 5 bytes, añade cinco bytes 0x05; si faltan 11, once 0x0B. Al descifrar, lo último que hace el algoritmo es mirar ese padding y comprobar que está bien formado.

Ahora la pieza que lo vuelve peligroso. AES en modo CBC encadena los bloques: cada bloque de texto cifrado se mezcla (con un XOR) en el descifrado del bloque siguiente. Piénsalo como una fila de fichas de dominó: tocar una mueve la de al lado. Eso le da al atacante una palanca: si manipula un bloque del texto cifrado, controla byte a byte lo que sale del bloque posterior, incluido ese padding final que el descifrador va a inspeccionar.

Y aquí está la trampa: que el padding sea válido o no es algo que el sistema delata. Si el atacante puede distinguir un fallo de “padding incorrecto” de cualquier otro (por un mensaje de error distinto, una línea de log, o simplemente porque la respuesta tarda un pelín más), entonces tiene un oráculo: una caja a la que hace una pregunta de sí/no por intento y que siempre le responde con la verdad. Es como un rival de ajedrez que, sin querer, hace una mueca cada vez que tu jugada le conviene: ese gesto, repetido, le basta para reconstruir la partida. Con ese único bit (“¿padding válido?”) preguntado las veces suficientes, va pelando el texto plano byte a byte, sin tener jamás la clave:

De Lucky Thirteen al Doom Principle

Ese es el ataque de oráculo de padding, descrito por primera vez por Serge Vaudenay en 2002, y lleva dos décadas rompiendo protocolos reales: Lucky Thirteen contra TLS en 2013, POODLE contra SSL 3.0 en 2014.

Moxie Marlinspike destiló la lección en The Cryptographic Doom Principle: “si tienes que realizar cualquier operación criptográfica antes de verificar el MAC de un mensaje que has recibido, de algún modo conducirá inevitablemente a la perdición”. Descifrar es una operación criptográfica. Así que la comprobación del MAC tiene que ir primero.

Tres maneras de combinar un cifrador y un MAC

Cifrar resuelve la confidencialidad (que nadie pueda leer el secreto), pero no la integridad (saber que nadie lo ha manipulado por el camino). De eso se encarga el MAC. Piénsalo como el sello de lacre de una carta antigua: el remitente estampa el lacre con un sello que solo él tiene (la clave), y el destinatario comprueba que sigue intacto antes de abrir; si alguien interceptó y reescribió la carta, el lacre ya no cuadra y se descarta sin leerla. El HMAC es ese sello en versión criptográfica: con una clave produce un tag corto a partir del mensaje, y cambiar un solo byte lo invalida.

El cifrado autenticado necesita, por tanto, las dos piezas: un cifrador (para la confidencialidad) y un MAC (para la integridad). Pero hay exactamente tres formas de ensamblarlas, y no son igual de seguras. El análisis canónico es el de Bellare y Namprempre, Authenticated Encryption: Relations among Notions and Analysis of the Generic Composition Paradigm (J. Cryptology, 2008), reforzado para canales seguros por el de Hugo Krawczyk, The Order of Encryption and Authentication for Protecting Communications.

Las tres composiciones genéricas

Las tres composiciones genéricas
ComposiciónQué haceUsada notablemente porVeredicto
Encrypt-and-MAC (E&M)MAC del texto plano, cifra el texto plano, envía ambosSSHNo es genéricamente seguro: el MAC puede filtrar texto plano, y tienes que descifrar para verificar
MAC-then-Encrypt (MtE)MAC del texto plano, luego cifra texto plano+MAC juntosTLS (suites CBC)Tienes que descifrar antes de poder comprobar: el camino de la perdición; origen de Lucky Thirteen
Encrypt-then-MAC (EtM)Cifra el texto plano, luego MAC del texto cifradoIPsecGenéricamente seguro. El MAC hace de guardián del texto cifrado, así que verificas sin descifrar

Encrypt-then-MAC es la única en la que puedes autenticar el mensaje sin tocar el cifrador: el tag se calcula sobre el texto cifrado, así que comprobarlo no requiere descifrado alguno. Esa propiedad es exactamente lo que mata al oráculo de padding: un fichero manipulado nunca alcanza el camino de descifrado. Por eso es lo que usa la bóveda.

Pon los dos órdenes uno al lado del otro y la perdición es evidente. La composición no es una taxonomía académica; es una decisión sobre qué paso se ejecuta primero cuando un atacante te entrega bytes:

Mismas primitivas, resultados opuestos

El sobre: 49 bytes de cabecera antes del texto cifrado AES-256-CBC

Cada registro cifrado de la bóveda (cada credencial, cada secreto TOTP, cada segmento del índice de listado) es un sobre autocontenido. La imagen es la de un sobre de correo: por fuera lleva en claro solo lo justo para manejarlo y verificarlo (la versión, el IV y el tag que hace de sello), y dentro viaja la carta cifrada. Todos siguen el mismo prefijo fijo:

La disposición de arriba es el sobre entero, pero el tag autentica un poco más de lo que el sobre contiene. Por delante del IV y del texto cifrado, el firmware antepone un prefijo de contexto autenticado de 8 bytes y aplica HMAC al conjunto (sobre SHA-256) con la sub-clave de MAC de los registros:

vault_envelope.h: el mensaje autenticado cpp
// authPrefix = ver(1) ‖ recordType(1) ‖ slotId(2, LE) ‖ generation(4, LE)
// authMsg    = authPrefix ‖ iv ‖ cipher
std::array<uint8_t, kEnvelopeContextSize> ctxBuf{};
writeEnvelopeContext(ctxBuf.data(), context);                // 8 B

// The three parts feed one multipart HMAC: no concatenation buffer exists.
VaultCrypto::hmacSha256Parts(macKey, macKeyLen, ctxBuf.data(), ctxBuf.size(),
                             iv, kIvSize, cipher, cipherLen, tagOut.data());

Ese prefijo de contexto no forma parte del sobre: el byte de versión se queda en 0x01 y la disposición del sobre no cambia ni un solo byte. recordType y slotId vienen de dónde vive el registro; generation viene del meta.bin autenticado. Las credenciales y las entradas TOTP se guardan como entradas de un registro de solo-añadir (un record log repartido en unos pocos ficheros de segmento de 32 slots cada uno), y la cabecera de cada entrada repite en claro su tipo, su slot y su generación para que un recorrido pueda encontrar entradas sin clave. Nadie se cree esas copias en claro: decide el tag, recalculado con la generación que guarda meta.bin. Así que el tag demuestra que este texto cifrado exacto, en este slot exacto, de este tipo exacto, en esta revisión exacta pertenece aquí, intacto, no solo que alguien que conocía la clave produjo algún texto cifrado. Por qué esos tres campos extra se ganan su sitio tiene su propia sección; primero, lo básico de escribir y leer uno.

El IV (vector de inicialización) son los 16 bytes aleatorios que siembran el encadenamiento de CBC para el primer bloque; sin él, dos registros con el mismo primer bloque descifrarían al mismo texto plano, filtrando su igualdad. La bóveda saca un IV nuevo del RNG hardware del ESP32 en cada escritura, así que guardar la misma contraseña dos veces produce dos textos cifrados completamente distintos: un atacante que lea la flash no puede ni siquiera saber que dos slots contienen el mismo valor. El IV no es secreto (se almacena en claro, ahí mismo en el sobre), pero debe ser impredecible y único por escritura, y autenticarlo bajo el tag impide que nadie lo sustituya sigilosamente.

Escribir un registro: cifrar, luego MAC

El camino de escritura es encrypt-then-MAC en el orden literal de su nombre. El texto plano se serializa primero a un registro binario empaquetado, siempre de 4.096 bytes exactos (más sobre esto más adelante), luego se cifra con AES-256-CBC bajo la sub-clave de cifrado, luego se calcula el tag sobre el texto cifrado bajo la sub-clave de MAC, y finalmente el sobre terminado se añade al record log:

save(): el camino de escritura encrypt-then-MAC (recortado) cpp
// Encrypt-then-MAC, assembled in place as [ver][iv][hmacTag][cipher].
env[kCredVerOffset] = kCredFormatVersion;
bool ok = VaultCrypto::encrypt(keys.enc.data(), plainBuf, plainLen,
                               iv, cipher, &cipherLen);
VaultCrypto::secureWipe(plainBuf, plainLen);
if (ok) ok = computeTag(keys, id, generation, iv, cipher, cipherLen, tagBuf);
std::memcpy(tag, tagBuf.data(), kCredHmacTagSize);
VaultCrypto::secureWipe(tagBuf.data(), tagBuf.size());

// Persist: append the envelope as one record-log entry under its generation.
RecordStore::writeEnvelope(LogEntryType::Credential, id, generation,
                           env, len, offset);
VaultCrypto::secureWipe(env, kCredMaxFileSize);   // the envelope buffer too

Fíjate en el borrado. El texto plano, las claves y los buffers de scratch contienen todos material secreto, y en un dispositivo que puede apagarse y sondearse, dejarlos en RAM es un pasivo. Cada buffer se limpia con una primitiva de puesta a cero que el compilador no puede optimizar y eliminar: mbedtls_platform_zeroize en el dispositivo, que existe precisamente porque un memset normal puede eliminarse como un “almacenamiento muerto”.

¿Por qué verificar el MAC antes de descifrar?

Descifrar primero abre un oráculo de padding: el descifrado inspecciona el padding PKCS#7 y filtra diferencias de tiempo o de error que un atacante puede explotar offline. Verificar el tag primero significa que ningún byte controlado por el atacante llega al cifrador, así que un fichero manipulado se rechaza sin descifrar ni un solo byte.

El camino de lectura en cinco pasos

Esta es la sección por la que existe todo el formato. Cuando la bóveda carga una credencial, hace cinco cosas en un orden estricto, y cualquier fallo en cualquier paso no devuelve nada:

decodeEnvelopeStatus(): verificar-antes-de-descifrar, con bloqueo ante fallos (recortado) cpp
// Fail-closed: wipe the caller's record up front, so EVERY reject path
// (bad size, version, MAC, decrypt, or decode) leaves no residue behind.
out.wipe();

// 1. Structural checks: plausible size, ciphertext is a whole number of blocks.
if (len < kCredMinFileSize || len > kCredMaxFileSize) return RecordLoad::Rejected;
const size_t cipherLen = len - kCredHeaderSize;
if ((cipherLen % kAesBlockSize) != 0)                 return RecordLoad::Rejected;

// 2. The envelope is [ver][iv][storedTag][cipher]; reject an unknown version.
if (env[kCredVerOffset] != kCredFormatVersion)        return RecordLoad::Rejected;

// 3. Verify-before-decrypt: recompute the tag and compare in CONSTANT TIME.
if (!computeTag(keys, id, generation, iv, cipher, cipherLen, expectedTag))
    return RecordLoad::Unreadable;  // could not compute: says nothing about flash
if (!VaultCrypto::constantTimeEqual(expectedTag.data(), storedTag,
                                    kCredHmacTagSize)) {
    // MAC mismatch → tampered or corrupt. Wipe scratch, produce no plaintext.
    return RecordLoad::Rejected;
}

// 4. Tag verified: only now is it safe to decrypt.
VaultCrypto::decrypt(keys.enc.data(), iv, cipher, cipherLen, plainBuf, &plainLen);

// 5. Decode the packed plaintext with a strict, fail-closed codec.
return decodeCredential(plainBuf, plainLen, out) ? RecordLoad::Ok
                                                 : RecordLoad::Rejected;

Dos detalles hacen esto seguro, en lugar de meramente secuencial.

Primero: la comparación en tiempo constante

Primero, la comparación en tiempo constante. Imagina un candado de combinación que hiciera clic (y tardara un pelín más en responder) cada vez que aciertas un dígito: adivinarlo a ciegas dejaría de ser desesperante, porque el propio candado te va soplando “caliente, caliente”. Una comparación de bytes normal filtra justo esa pista. “Tiempo constante” aquí significa algo concreto: la comparación lleva el mismo trabajo con independencia de los datos, así que su duración no le dice nada al observador sobre el secreto. Un memcmp ingenuo hace lo contrario: retorna en el instante en que encuentra el primer byte que difiere. Aliméntalo con un tag adivinado y el tiempo que tarda en decir “no” revela cuántos bytes iniciales has acertado: un tag que coincide en los primeros 3 bytes retorna medible­mente más tarde que uno que falla en el byte 0. Un atacante que pueda medir eso convierte la falsificación del tag en una búsqueda byte a byte, ~256 intentos por byte en lugar de 2256 para el tag entero. La bóveda nunca usa memcmp sobre datos que dependen de secretos; usa una comparación sin ramas que acumula con XOR cada diferencia de byte en un único valor y solo comprueba ese valor al final del todo (el mismo número de operaciones tanto si el tag coincide en el byte 0 como en el byte 31):

La idea de una comparación en tiempo constante cpp
// The vault calls Mbed TLS's mbedtls_ct_memcmp; this is the idea it implements:
bool constantTimeEqual(const uint8_t* a, const uint8_t* b, size_t len) {
    volatile uint8_t diff = 0;            // 'volatile' stops the optimizer
    for (size_t i = 0; i < len; i++)      // always touch EVERY byte
        diff |= a[i] ^ b[i];
    return diff == 0;                     // one check, at the very end
}

La bóveda no escribe ese bucle a mano: llama a mbedtls_ct_memcmp de Mbed TLS, el primitivo de comparación en tiempo constante de la librería, que hace exactamente esto. Si nunca has visto por qué importa, A Lesson In Timing Attacks de Coda Hale es la clásica explicación de cinco minutos; las notas sobre tiempo constante de BearSSL van más a fondo.

Segundo: bloqueo ante fallos por diseño

Segundo, bloqueo ante fallos (fail-closed). El registro se borra antes de leer nada, así que no hay ningún camino de código (ni un tamaño incorrecto, ni una versión incorrecta, ni un MAC que no coincide, ni un fallo de descifrado, ni un registro malformado) que pueda dejar una credencial parcialmente poblada en el buffer del llamante. La función o bien retorna Ok con un registro completo, o una negativa sin nada. Incluso distingue dos negativas: Rejected significa que los bytes guardados se leyeron y se rechazaron, Unreadable que la comprobación ni siquiera pudo ejecutarse (un buffer ocupado, un fallo de recursos criptográficos), lo que no dice nada de lo que hay en la flash.

Qué vincula realmente el tag: slot, tipo y frescura

Un tag sobre ver ‖ iv ‖ cipher demuestra que el texto cifrado es genuino, pero no dice nada sobre tres cosas que el formato también necesita prometer: a qué slot pertenece un fichero, qué tipo de registro es, y cómo de reciente es. Un atacante con escritura en bruto de la flash (un programador o el puerto de depuración) pero sin PIN puede convertir cada silencio en un ataque, y cada uno tiene su equivalente cotidiano: echar una carta en el buzón equivocado (slot equivocado), cambiar la etiqueta de una caja para que pase por otra cosa (tipo equivocado), o colar el recibo del mes pasado como si fuera el de hoy (una revisión obsoleta):

Tres huecos que deja un tag solo sobre el texto cifrado

Tres huecos que deja un tag solo sobre el texto cifrado
Ataque (escritura en bruto de flash, sin PIN)Por qué funcionaba
Sustitución entre slots: copiar cred_05.bin sobre cred_03.bin (entonces cada registro era un fichero)Ambos sellados con la misma clave, así que el fichero reubicado verificaba y servía el secreto del slot 5 como si fuera el slot 3: la contraseña de un sitio tecleada en el formulario de otro
Confusión entre tipos: soltar un fichero de credencial en una ruta de TOTPMisma clave y mismo byte de versión; solo la comprobación de forma del decodificador de registros se interponía, no un discriminador autenticado
Rollback selectivo: restaurar un fichero más antiguo, pero auténtico, para un slotNada registraba cómo de fresco debía ser un fichero, así que un sobre obsoleto-pero-genuino (una contraseña que acabas de rotar) seguía verificando

Vincular el contexto que falta al propio tag

El arreglo vincula el contexto que falta al propio tag. Este es exactamente el papel de los datos asociados en un esquema AEAD (contexto que debe estar autenticado pero no cifrado), salvo que aquí se pliega directamente en el tag de encrypt-then-MAC en lugar de pasarse a un cifrador de una sola pasada. El prefijo de 8 bytes está centralizado en un único sitio, así que cada módulo de registro (y el camino de rotación de claves) lo calcula bit a bit idéntico. slotId es un uint16 little-endian, así que cada slot de la placa de mayor capacidad tiene su propio valor autenticado:

vault_envelope.h: writeEnvelopeContext (un prefijo centralizado) cpp
// ver(1) ‖ recordType(1) ‖ slotId(2, LE) ‖ generation(4, LE)
inline size_t writeEnvelopeContext(uint8_t* out,
                                   const EnvelopeContext& context) noexcept {
    out[0] = kEnvelopeVersion;                   // 0x01, unchanged on disk
    out[1] = std::to_underlying(context.type);   // Credential / Totp / ...
    out[2] =  context.slot       & 0xFF;         // the slot this record is,
    out[3] = (context.slot >> 8) & 0xFF;         //   little-endian u16
    out[4] =  context.generation        & 0xFF;  // per-slot freshness counter,
    out[5] = (context.generation >> 8)  & 0xFF;  //   little-endian u32
    out[6] = (context.generation >> 16) & 0xFF;
    out[7] = (context.generation >> 24) & 0xFF;
    return kEnvelopeContextSize;                 // 8
}

Así, un fichero reubicado lleva el slotId equivocado, un fichero retipado el recordType equivocado, y un fichero revertido un generation antiguo. En cada caso el tag que el firmware recalcula ya no coincide con el del disco, así que la lectura se bloquea ante el fallo antes de descifrar un solo byte. Es la misma puerta de verificar antes de descifrar, que cubre tres promesas más:

La raíz de confianza: autenticar meta.bin

Cada comprobación hasta aquí se ha apoyado en que algo ya era de fiar: las claves, los contadores de frescura. Esa confianza tiene que tocar fondo en algún sitio: en una raíz de confianza, la única pieza que no te toca cuestionar, porque si estuviera falseada todo lo construido encima heredaría la mentira. Aquí esa pieza es meta.bin.

Los contadores de generation tienen que vivir en algún sitio, y ese sitio tiene que ser a prueba de manipulación: de lo contrario un atacante simplemente bajaría un contador para hacer que un fichero obsoleto pareciera actual. Viven en meta.bin, el pequeño registro que el dispositivo lee primero, y son dos mitades autenticadas, cada una con su tag.

Hay dos mitades porque el PIN no produce directamente las claves de los registros. PBKDF2 lo convierte en una clave de cifrado de clave (KEK, key-encryption key), y la KEK desenvuelve una clave de bóveda aleatoria de 32 bytes, que es a la que están ligados todos los registros. La primera mitad guarda lo que tiene que abrir el secreto por sí solo: el salt y el factor de trabajo de la KDF, el verificador de PIN, la clave de bóveda envuelta y un tag bajo una clave derivada de la KEK. Solo una operación que tenga el secreto puede reemplazarla.

La segunda mitad es la que puede reescribir cualquier sesión desbloqueada, y eso es lo que permite que un guardado normal incremente un contador de generation sin volver a pedir el PIN: el salt de HMAC, el estado de una rotación de claves y una tabla de generation por slot para credenciales y para entradas TOTP, sellado por un tag bajo una clave derivada de la clave de bóveda.

Hay dos tags porque hay dos escritores. Un solo tag bajo una clave derivada del PIN obligaría a repetir PBKDF2 en cada guardado (unos 1,55 s en un ESP32-S3); un solo tag bajo una clave derivada de la clave de bóveda obligaría a desenvolver esa clave antes de haber autenticado nada, un descifrar-antes-de-verificar en el envoltorio. Así que cada mitad la sella una clave que tiene su escritor legítimo.

La secuencia de desbloqueo se convierte en una escalera cuidadosa: nada del fichero se confía hasta que un tag verifica, y un PIN equivocado deriva una KEK distinta, así que el tag de envoltorio y el verificador de PIN fallan ambos a la vez.

El paso dos tiene que ejecutarse antes de que haya nada autenticado, así que el factor de trabajo guardado llega a PBKDF2 sin verificar. El parseo comprueba su rango, de modo que un registro manipulado no puede ni bloquear el dispositivo más allá de su watchdog ni convencerlo de una derivación débil, y editarlo no permite falsificar nada: el verificador solo se reproduce con el recuento con el que se aprovisionó la bóveda.

El byte de formato no se mueve durante el desarrollo. Se queda en 0x01 mientras la disposición cambia, porque antes del lanzamiento el único camino de despliegue es borrar la flash y volver a aprovisionar, así que un dispositivo solo conoce la disposición actual. El parseo es estricto con ese byte, de modo que un registro ajeno o antiguo se bloquea ante el fallo y la bóveda se re-aprovisiona. No hay migración ni doble lectura. (El dispositivo no está en producción, así que no hay datos reales que migrar.)

Lo que cuesta cambiar el PIN

Envolver la clave de bóveda es lo que abarata el cambio de PIN. Reescribe la primera mitad, de 154 bytes, dentro de una única escritura atómica de meta.bin, y todos los registros de la flash se quedan exactamente como estaban. Sin el envoltorio, cambiar el PIN recifra la bóveda entera, necesita libre más o menos el doble de lo que ocupa la bóveda y, por tanto, no puede completarse en un dispositivo casi lleno.

Tiene un precio, y el proyecto lo pone el primero de su lista de riesgos aceptados. La clave de bóveda no cambia nunca salvo que ejecutes la acción explícita de Recifrar la bóveda, así que cambiar el PIN ya no revoca una clave. Un PIN retirado más cualquier copia de meta.bin tomada antes del cambio desenvuelve la misma clave de bóveda, y con ella cada credencial escrita después. Eso solo necesita acceso de lectura, sin escribir en la flash; en los builds endurecidos el envoltorio está ligado al dispositivo, así que además hace falta el chip original. Ese mismo meta.bin antiguo, reescrito con acceso en bruto a la flash, hace que el PIN retirado abra la bóveda actual y recupera el factor de trabajo con el que se derivó.

El remedio es esa acción separada de recifrado: recorre todos los registros con una clave de bóveda nueva, un paso confirmado cada vez para que un corte de corriente la deje reanudable, y es la que hay que usar cuando un PIN puede haberse filtrado. Es la misma división que hacen LUKS y VeraCrypt: la frase de paso envuelve una clave de volumen, y recifrar el volumen es una operación aparte, más lenta, que eliges tú.

¿Cómo se hace el anti-rollback resistente a cortes de corriente?

El rollback selectivo es un ataque de repetición (el atacante re-presenta un fichero auténtico-pero-obsoleto) y la defensa estándar es un valor de frescura: un contador monótono que el verificador espera que solo aumente, de modo que un registro antiguo repetido falle la comprobación. Pero un contador de frescura solo vale tanto como su historia de actualización. Cada mutación (añadir, editar o borrar) incrementa el generation del slot, y el registro se vuelve a escribir bajo el nuevo valor. Eso crea un riesgo de escritura partida (torn write): pierde corriente entre escribir el registro (bajo generation N+1) y escribir meta.bin (que registra N+1), y los dos discrepan. Resuelve eso mal y o bien dejas inservible un slot válido o bien reabres en silencio el rollback que acabas de cerrar.

La solución es elegir un único instante indivisible que “cuente”, como la firma de un contrato: antes de que el bolígrafo se levante nada es vinculante, después lo es todo. El firmware hace que el intercambio de meta.bin sea ese instante (se escribe en un fichero temporal y luego se renombra atómicamente a su sitio, así que un lector siempre ve el meta.bin antiguo o el nuevo, nunca una escritura partida a medias) y se recupera de forma determinista en torno a él:

Un append que se cerró está en la flash, pero si está vivo lo decide solo meta.bin: la entrada viva de un slot es la que tiene la generation que marca la tabla autenticada. Si se va la corriente antes del commit, la entrada nueva lleva una generation que meta.bin nunca llegó a registrar, así que es basura y el slot conserva su registro anterior. Si se va después del commit y antes de escribir el índice, la fila del índice queda obsoleta; la comprobación del tag del propio segmento lo detecta y la fila se reconstruye a partir del log. Cada vez que meta.bin se autentica, el almacén además vuelve a añadir la entrada confirmada encima de cualquier huérfana, para que la presencia, los recuentos y la compactación coincidan con lo que creen los lectores. La compactación copia las entradas vivas de un segmento a un gemelo .tmp y lo renombra sobre el segmento, así que un segmento nunca falta. Ninguna ventana deja inservible un slot ni reabre en silencio el rollback, y esas interrupciones tienen sus propios tests nativos. Un delete añade una lápida (tombstone) e incrementa la generation en el mismo commit, así que una entrada borrada no puede resucitarse repitiendo sus bytes antiguos.

El límite honesto: open vs _secure

Vincular generation cierra el rollback selectivo: revertir un registro mientras el resto de la bóveda avanza. No cierra un rollback de instantánea completa: revierte la bóveda entera (meta.bin y cada registro juntos) a un estado anterior internamente consistente, y los contadores revierten con él, así que nada parece fuera de lugar. En los builds open (flasheables por desarrolladores) nada lo detecta. Los builds endurecidos _secure suben el listón sin cerrarlo: Flash Encryption y Secure Boot v2 impiden que un atacante falsifique un estado nuevo con aspecto de antiguo, pero alguien con acceso físico todavía puede volver a escribir una imagen cifrada que capturó antes del mismo dispositivo. Un anti-rollback monótono de verdad necesita un contador monótono en hardware, y los bits eFuse de un solo uso del chip no son sitio para un estado que cambia tan a menudo.

Qué rollback atrapa cada build
Variante de rollbackBuilds openBuilds _secure
Selectivo: un registro revertido, meta.bin avanzaCerrado: falla el MAC vinculado a la generationCerrado
Instantánea completa: meta.bin + cada registro revertidos juntosNo cerrado: todo el conjunto es autoconsistenteMás difícil, no cerrado: falsificar un estado antiguo exige las claves de la flash, pero una imagen capturada del mismo dispositivo todavía puede volver a escribirse

Prefiero enunciar ese límite con claridad antes que sobrevenderlo. El contador de generation es defensa en profundidad frente al ataque selectivo, mucho más práctico; el salto temporal de toda la bóveda sigue siendo un riesgo residual documentado en todos los builds, acotado en los endurecidos por el cifrado de la flash y por el acceso físico. Además, la clave de bóveda envuelta le dio dientes: como muestra lo que cuesta cambiar el PIN, reescribir solo un meta.bin antiguo devuelve la vida a un PIN retirado.

¿Necesita encrypt-then-MAC claves separadas?

Sí. El argumento de seguridad de encrypt-then-MAC asume que la clave de cifrado y la de autenticación son independientes; reutilizar una sola clave para ambos trabajos anula la garantía e invita a travesuras entre protocolos, así que la bóveda deriva una sub-clave para cifrar y otra para el MAC a partir de la misma clave de bóveda, cada una bajo su propia etiqueta de separación de dominio. La KEK que sale del PIN recibe el mismo trato para sus propios trabajos.

Es el mismo instinto que dice que la llave de casa, la del coche y la del buzón deberían ser tres llaves distintas, y sigue la guía de separación de claves de toda la vida; el NIST SP 800-57 Part 1 lo dice sin rodeos: “una clave debería usarse para un solo propósito”:

vault_keys: etiquetas distintas por propósito cpp
// The PIN-derived KEK opens the vault; the random vault key (VK) keys the records.
//   pinVerifier = HMAC(KEK, "kleidos-pin")              -> unlock check
//   wrapEnc     = HMAC(KEK, "kleidos-wrap-enc")         -> unwraps the VK
//   wrapMac     = HMAC(KEK, "kleidos-wrap-mac")         -> tags meta.bin's KEK half
//   encKey      = HMAC(VK,  "kleidos-enc")              -> AES-256-CBC key
//   macKey      = HMAC(VK,  "kleidos-mac" || hmacSalt)  -> record HMAC key
//   metaMac     = HMAC(VK,  "kleidos-meta-mac")         -> tags meta.bin's VK half
bool ok = deriveLabeled(vaultKey, kEncKeyLabel, out.enc.data(), out.enc.size());
ok = ok && deriveMacKey(vaultKey, hmacSalt, out.mac);  // label || hmacSalt
ok = ok && deriveLabeled(vaultKey, kMetaMacKeyLabel,
                         out.metaMac.data(), out.metaMac.size());

¿Cómo se comprueba un PIN sin almacenarlo?

El verificador de PIN es cómo el dispositivo comprueba el PIN sin almacenarlo: el desbloqueo deriva la KEK del PIN introducido, calcula el verificador y lo compara, de nuevo en tiempo constante, con el valor almacenado, cuando el tag de envoltorio ya ha verificado. Un PIN equivocado simplemente produce un verificador distinto, sin ninguna pista sobre cuán equivocado estaba. (El verificador está en la primera mitad de meta.bin, junto al salt; el PIN en sí nunca se escribe en ninguna parte.)

El sentido de la separación es que cada clave puede hacer exactamente una cosa. Si la misma clave cifrara los ficheros y los autenticara, un atacante astuto podría intentar que el texto cifrado y los tags interactuaran, y la prueba de seguridad limpia de encrypt-then-MAC, que asume que las dos claves son independientes, simplemente dejaría de aplicar. Mantenerlas separadas implica que un compromiso o un mal uso de una capacidad no puede tomar prestada otra:

Defensa en profundidad: padding en tiempo constante + un códec con bloqueo ante fallos

Como el MAC se verifica primero, un fichero manipulado nunca alcanza el código de descifrado, lo que significa que el clásico oráculo de padding es inalcanzable por construcción. La comprobación de padding de abajo es, por tanto, defensa en profundidad, no la defensa principal: un cinturón además de los tirantes para el caso en que datos autenticados-pero-corruptos aún necesiten rechazarse limpiamente.

La bóveda aplica y comprueba el padding PKCS#7 por su cuenta. AES corre a través de PSA con PSA_ALG_CBC_NO_PADDING: el firmware rellena el texto plano antes de cifrar y, tras descifrar (es decir, solo cuando el MAC ya ha pasado), valida el padding en tiempo constante e informa de un único fallo unificado, así que no filtra nada a través de logs ni del tiempo, pase lo que pase. El modo CBC-PKCS7 de la propia biblioteca no se usa nunca, y por eso tampoco aplica a la bóveda CVE-2025-59438, una fuga de tiempo en los errores de padding de ese modo. (La clave de bóveda de 32 bytes se envuelve sin ningún relleno: son exactamente dos bloques.)

Comprobación de padding PKCS#7 en tiempo constante, fallo unificado cpp
// The last byte claims the pad length; fold every check into one mask.
const uint8_t padByte    = plainOut[cipherLen - 1];
uint8_t       padInvalid = 0;
padInvalid |= static_cast<uint8_t>(padByte == 0);             // length 0: illegal
padInvalid |= static_cast<uint8_t>(padByte > kAesBlockSize);  // > a block: illegal
for (size_t i = 0; i < kAesBlockSize; i++) {
    const uint8_t mask = (i < padByte) ? 0xFF : 0x00;         // examine the whole block
    padInvalid |= static_cast<uint8_t>((plainOut[cipherLen - 1 - i] ^ padByte) & mask);
}
if (padInvalid != 0) {   // the caller logs ONE message for corruption AND bad padding
    mbedtls_platform_zeroize(plainOut, cipherLen);
    return false;
}
*plainLen = cipherLen - padByte;

El texto plano descifrado se entrega luego a un códec binario propio, no a un parser de propósito general. Los bytes descifrados son el rango más sensible de todo el producto, y no quiero una biblioteca de JSON o CBOR (con su superficie de ataque, sus asignaciones de memoria, sus sorpresas) en ningún punto cercano a ellos. El decodificador es un cursor de solo avance donde cada lectura comprueba sus límites antes de copiar, cada longitud de cadena se rechaza si excede el tope de compilación del campo, y cualquier basura sobrante hace fallar el registro:

record_codec: un lector con límites comprobados y bloqueo ante fallos cpp
// need(n): are n more bytes available, without integer-overflow wraparound?
bool need(size_t n) const noexcept { return off_ + n <= len_ && off_ + n >= off_; }

// Read a length-prefixed string: reject len > field-capacity BEFORE copying.
template <size_t N>
bool readStrBody(InplaceString<N>& dst, size_t len) noexcept {
    if (len > N || !need(len)) return false;   // fail closed, no partial copy
    std::memcpy(dst.data(), p_ + off_, len);
    dst.data()[len] = '\0';                     // NUL-terminate the destination
    dst.resyncLength();
    off_ += len;
    return true;
}

¿Por qué CBC+HMAC y no AES-GCM?

Una pregunta justa. El estándar moderno por defecto para el cifrado autenticado es una construcción AEAD como AES-GCM, que funde cifrado y autenticación en una sola primitiva y es más difícil de ensamblar mal. Si arrancara un protocolo desde cero, GCM (o ChaCha20-Poly1305) sería la elección obvia.

La bóveda usa encrypt-then-MAC con AES-256-CBC y HMAC-SHA256 por razones que tienen que ver con este dispositivo, no con las primitivas. CBC con HMAC no tiene un modo de fallo catastrófico si un IV llega a repetirse: un IV repetido revela que dos registros comparten sus primeros bloques, no una clave, mientras que un nonce de GCM repetido entrega la clave de autenticación. AES y SHA-256 tienen aceleración hardware en el ESP32. Y lo difícil de este formato, la frescura, es un problema de estado que los datos asociados no resolverían: un AEAD llevaría el mismo type ‖ slot ‖ generation en sus datos asociados, con una primitiva más arriesgada debajo. Encrypt-then-MAC es la composición genéricamente segura, así que construir AEAD a partir de CBC+HMAC de este modo es sólido; es como lo hacen IPsec (en su configuración estándar) y KDBX 4 también.

Hubo una tercera razón: una salida byte a byte idéntica entre un build de host cuyos tests unitarios corrían contra la API legacy de Mbed TLS y un dispositivo donde la API PSA Crypto de Mbed TLS 4.0 es el único camino público. Esa ya no existe: los tests de host enlazan ahora también Mbed TLS 4 y TF-PSA-Crypto, así que los dos lados usan el mismo backend PSA y no queda una segunda API que mantener a la par. Las versiones se separan por una versión de parche (el ESP-IDF 6.0.3 del dispositivo trae Mbed TLS 4.1.1 con TF-PSA-Crypto 1.1.1; el host compila Mbed TLS 4.1.0 con TF-PSA-Crypto 1.1.0), y lo que ancla su salida son los estándares, no la versión: los dos pasan las primitivas de producción por los mismos vectores publicados de respuesta conocida (PBKDF2-HMAC-SHA256 contra RFC 7914 §11, HMAC-SHA256 contra el caso de prueba 1 de RFC 4231), y el dispositivo comprueba además AES-256-CBC contra NIST SP 800-38A.

AES-GCM (AEAD)

Una primitiva, una clave. Cifrado y autenticación están fundidos; menos que cablear a mano.

  • Estándar moderno por defecto; resistente al mal uso si los nonces son únicos
  • La reutilización de nonce es catastrófica (recuperación de clave)
  • La unicidad del nonce tiene que sobrevivir a cada escritura, reinicio y restauración
AES-CBC + HMAC (EtM)

Dos primitivas, dos claves, orden explícito. Más piezas móviles, pero cada una es simple y determinista.

  • Un IV repetido revela igualdad, no una clave
  • AES y SHA-256 con aceleración hardware en el ESP32
  • EtM es demostrablemente la composición segura
  • Un IV aleatorio nuevo por escritura, autenticado por el tag

Una nota al pie sobre la implementación: la guía de transición de PSA sugiere psa_mac_verify antes que “calcula un tag, luego mbedtls_ct_memcmp”. El camino de los registros sigue calculando y comparando, con una sola función de tag compartida por la escritura y la lectura; la exportación cifrada pasa sus bloques por psa_mac_verify. Las dos comparaciones son en tiempo constante.

¿Cómo lista la bóveda las credenciales sin descifrarlas todas?

El índice de la lista es un conjunto de segmentos cifrados, idx_NN.bin, uno por cada 32 slots de credencial, cada uno en exactamente el mismo sobre [ver][iv][hmacTag][cipher]. Cachean solo los metadatos no secretos que necesita una vista de lista: id, nombre, usuario, marca, el dominio sacado de la URL, grupo, orden, fecha de modificación y dos flags (favorito, tiene 2FA). Nunca la contraseña, la URL completa, las notas ni el secreto TOTP. Listar cuesta por tanto una lectura y un descifrado por cada 32 slots, no uno por credencial.

Eso sale de tratar cada artefacto como un sobre independiente. Descifrar cada credencial solo para pintar una lista sería lento y expondría innecesariamente cada secreto en RAM; el índice se ahorra las dos cosas. Cada segmento vincula su propio número como slotId y, como generation, la suma de los contadores de credencial de los slots que cachea, así que una copia antigua de un segmento falla su tag como cualquier registro revertido. Partirlo también mantiene fijo el conjunto de trabajo: un único fichero de índice necesitaba RAM en proporción al número de credenciales, y una placa sin PSRAM se quedaba sin ella mucho antes de su capacidad nominal.

Bloqueo ante fallos, por construcción

No es el cifrador lo que hace esta bóveda digna de confianza en reposo; es el orden de las operaciones. Dicho en simple: nunca te fíes de un fichero hasta que su sello cuadre y, ante la duda, no entregues nada. Autentica el slot, el tipo, la frescura, el IV y el texto cifrado con una clave separada; verifica ese tag en tiempo constante antes de descifrar un solo byte; autentica los metadatos que contienen los contadores de frescura; borra todo al salir; y haz que cada camino de error converja en la misma respuesta: nada. Acierta en eso y AES-256 es casi un detalle de implementación.

Encrypt-then-MAC, vinculado al contexto, verificar antes de descifrar, con bloqueo ante fallos

Vincular slot, tipo y un contador de frescura en el tag (y autenticar los metadatos que contienen esos contadores) elimina la clase del oráculo de padding y derrota la reubicación, la confusión de tipos y el rollback selectivo antes de cualquier descifrado. Los huecos residuales están documentados: el rollback de instantánea completa (más difícil pero no cerrado en los builds _secure), un cambio de PIN que reenvuelve la clave de bóveda en vez de revocarla, y la baja entropía de un PIN corto (el próximo artículo).

Hay una cosa que este formato no resuelve, sin embargo. Todo él protege el fichero, pero la clave que desbloquea la bóveda al final viene de un PIN corto, y un PIN corto tiene muy poca entropía. En el dispositivo eso está bien: una protección contra fuerza bruta bloquea y acaba borrando la bóveda tras un puñado de PINs equivocados, así que el adivinado online muere rápido. El problema es el offline. Si alguien desuelda la flash y copia el texto cifrado, el bloqueo nunca se ejecuta: pueden derivar claves a partir de PINs adivinados en una GPU, sin ningún dispositivo en el bucle que los detenga, y un espacio de claves de 4 dígitos cae en una fracción de segundo. (La flash encryption de producción sube ese listón, pero el formato de fichero no debería tener que depender de ella.) Cerrar ese hueco necesita un truco distinto: uno que mezcle en la clave un secreto que solo el chip original pueda producir, de modo que el texto cifrado exfiltrado sea inútil en cualquier otro hardware. Ese es el próximo artículo.

Preguntas frecuentes

¿Qué es encrypt-then-MAC?

Encrypt-then-MAC autentica el texto cifrado antes de cualquier descifrado. Cada fichero de la bóveda es un sobre ([ver][iv][hmacTag][cipher]) y el tag HMAC se verifica en tiempo constante antes de que se ejecute AES, así que un fichero manipulado se rechaza sin descifrarse jamás.

¿Por qué verificar el MAC antes de descifrar?

Descifrar primero abre un oráculo de padding: el paso de descifrado inspecciona el padding PKCS#7 y filtra diferencias de tiempo o de error que un atacante explota offline. Verificar el tag primero hace que ningún byte controlado por el atacante llegue al cifrador: la bóveda se bloquea ante el fallo (fail-closed).

¿Cómo impide el formato un fichero de bóveda revertido o reubicado?

El HMAC autentica un prefijo de contexto (ver ‖ recordType ‖ slotId ‖ generation), así que un fichero movido a otro slot, retipado o sustituido por una revisión más antigua falla el tag, incluso frente a un atacante con acceso en bruto a la flash y sin PIN.

¿Necesita encrypt-then-MAC claves separadas?

Sí. El cifrado y la autenticación usan claves independientes, cada una derivada de la clave de bóveda con su propia etiqueta; el PIN desbloquea esa clave de bóveda en vez de producir directamente las claves de los registros. Reutilizar una sola clave para el cifrador y para el MAC debilita la construcción y debe evitarse.

¿Por qué AES-256-CBC más HMAC en lugar de AES-GCM?

No por preferencia criptográfica: en un protocolo nuevo GCM o ChaCha20-Poly1305 serían la elección obvia. CBC con HMAC no tiene un modo de fallo catastrófico si un IV llega a repetirse (un IV repetido revela que dos registros comparten sus primeros bloques, no una clave), AES y SHA-256 tienen aceleración hardware en el ESP32, y lo difícil del formato, la frescura, es un problema de estado que los datos asociados no resolverían. Un motivo anterior, una salida byte a byte idéntica desde dos backends de Mbed TLS en host y dispositivo, ya no aplica: los tests de host enlazan ahora también Mbed TLS 4 y TF-PSA-Crypto, el mismo backend PSA que el firmware, una versión de parche por detrás. Encrypt-then-MAC es la composición genéricamente segura, y es lo que usan también IPsec en su configuración estándar y KDBX 4.

¿Cómo lista la bóveda las credenciales sin descifrarlas todas?

El índice de la lista es un conjunto de segmentos cifrados, uno por cada 32 slots de credencial, en el mismo sobre [ver][iv][hmacTag][cipher]. Guardan solo los metadatos no secretos que necesita una vista de lista: id, nombre, usuario, marca, el dominio sacado de la URL, grupo, orden, fecha de modificación y dos flags. Nunca contienen contraseñas, URLs completas, notas ni secretos TOTP, así que listar la bóveda cuesta un descifrado por cada 32 slots en lugar de uno por credencial, y no expone ningún secreto en RAM solo para pintar una lista.