Una bóveda con bloqueo ante fallos: Encrypt-then-MAC en un MCU
Cómo un gestor de contraseñas hardware autentica cada fichero antes de descifrar: Encrypt-then-MAC, verificación antes de descifrar y bloqueo ante fallos.

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
- 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 (stage → commit → promote). - 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í la clave nace de tu PIN.
- 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.
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:
- Roba el bloque de texto cifrado que quiere leer (el bloque objetivo) y el bloque anterior, el que va justo antes.
- Manipula un byte del bloque anterior y reenvía el texto cifrado modificado para que se descifre. En CBC, el bloque anterior se mezcla (con un XOR) en el descifrado del siguiente, así que ese byte controla lo que sale.
- Una respuesta distinguible de 'padding incorrecto' frente a 'otro error' revela un byte intermedio — y de él, un byte de texto plano.
- Repite sobre los 256 valores posibles, luego las 16 posiciones, luego cada bloque, hasta recuperar el fichero entero.
- Encrypt-then-MAC: la comprobación del MAC falla primero, así que el camino de descifrar + quitar padding nunca se alcanza. El oráculo no llega a responder.
| Paso | Tipo | Acción |
|---|---|---|
| 1 | acción del atacante | Roba el bloque de texto cifrado que quiere leer (el bloque objetivo) y el bloque anterior, el que va justo antes. |
| 2 | paso repetido | Manipula un byte del bloque anterior y reenvía el texto cifrado modificado para que se descifre. |
| 3 | información filtrada | Una respuesta distinguible de 'padding incorrecto' frente a 'otro error' revela un byte intermedio — y de él, un byte de texto plano. |
| 4 | acción del atacante | Repite sobre los 256 valores posibles, luego las 16 posiciones, luego cada bloque, hasta recuperar el fichero entero. |
| 5 | bloqueado por diseño | Encrypt-then-MAC: la comprobación del MAC falla primero, así que el camino de descifrar + quitar padding nunca se alcanza. El oráculo no llega a responder. |
Cada consulta produce un bit — '¿padding válido?' — y ese bit basta para recuperar un byte, luego un bloque, luego el fichero. Encrypt-then-MAC elimina el oráculo al no llegar nunca al paso de descifrado con un fichero manipulado.
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.
| Composición | Qué hace | Usada notablemente por | Veredicto |
|---|---|---|---|
| Encrypt-and-MAC (E&M) | MAC del texto plano, cifra el texto plano, envía ambos | SSH | No 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 juntos | TLS (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 cifrado | IPsec | Gené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:
MAC-then-Encrypt (el camino de la perdición)
debe descifrar antes de poder comprobar
- Recibe un fichero posiblemente manipulado
- Descífralo — toca bytes del atacante y quita el padding PKCS#7 aquí es donde el oráculo filtra información
- Solo ahora comprueba el MAC — ya demasiado tarde
Encrypt-then-MAC (la bóveda)
comprueba antes de descifrar
- Recibe un fichero posiblemente manipulado
- Recalcula y comprueba primero el MAC sobre el texto cifrado no hace falta descifrar para esto
- Descifra solo si el tag verificó
| Ruta | Paso | Acción | Estado |
|---|---|---|---|
| MAC-then-Encrypt (el camino de la perdición) | 1 | Recibe un fichero posiblemente manipulado | paso |
| MAC-then-Encrypt (el camino de la perdición) | 2 | Descífralo — toca bytes del atacante y quita el padding PKCS#7 | inseguro — bytes del atacante tocados antes de la comprobación |
| MAC-then-Encrypt (el camino de la perdición) | 3 | Solo ahora comprueba el MAC — ya demasiado tarde | inseguro — bytes del atacante tocados antes de la comprobación |
| Encrypt-then-MAC (la bóveda) | 1 | Recibe un fichero posiblemente manipulado | paso |
| Encrypt-then-MAC (la bóveda) | 2 | Recalcula y comprueba primero el MAC sobre el texto cifrado | seguro — verificado antes de cualquier descifrado |
| Encrypt-then-MAC (la bóveda) | 3 | Descifra solo si el tag verificó | seguro — verificado antes de cualquier descifrado |
MAC-then-Encrypt fuerza un descifrado sobre entrada controlada por el atacante antes de la comprobación de integridad; Encrypt-then-MAC hace de guardián con el tag, así que un fichero falsificado se rechaza antes de que el cifrador llegue a ejecutarse.
El sobre: [ver][iv][hmacTag][cipher]
Cada fichero cifrado de la bóveda —cada credencial, cada secreto TOTP, el í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:
- ver · 1 B · @0 — versión del formato
- IV · 16 B · @1 — aleatorio por escritura
- hmacTag · 32 B · @17 — HMAC-SHA256
- cipher · … · @49+ — AES-256-CBC
| Campo | Offset | Tamaño |
|---|---|---|
| ver | @0 | 1 B |
| IV | @1 | 16 B |
| hmacTag | @17 | 32 B |
| cipher | @49+ | … |
El tag HMAC se almacena en línea, entre el IV y el texto cifrado — sin ficheros adjuntos aparte.
La disposición en disco de arriba es el fichero entero — pero el tag autentica un poco más de lo que el fichero contiene. Por delante del IV y del texto cifrado, el firmware antepone un prefijo de contexto autenticado de 7 bytes y aplica HMAC al conjunto (sobre SHA-256) con la sub-clave de MAC del fichero:
// authPrefix = ver(1) ‖ recordType(1) ‖ slotId(1) ‖ generation(4, LE)
// authMsg = authPrefix ‖ iv ‖ cipher
std::array<uint8_t, kEnvelopeContextSize + kIvSize + MaxCipher> message{};
size_t n = writeEnvelopeContext(message.data(), type, slot, generation); // 7 B
std::memcpy(message.data() + n, iv, kIvSize); n += kIvSize;
std::memcpy(message.data() + n, cipher, cipherLen); n += cipherLen;
VaultCrypto::hmacSha256(macKey, macKeyLen, message.data(), n, tagOut.data());
VaultCrypto::secureWipe(message.data(), message.size());Ese prefijo de contexto nunca se escribe en el fichero — el byte de versión en disco se queda en 0x01 y la disposición no cambia ni un solo byte. recordType y slotId vienen de dónde vive el fichero (qué ruta, qué slot); generation viene del meta.bin autenticado. 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 (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 se escriben las cuatro partes:
// Encrypt-then-MAC: encrypt the record, then authenticate the ciphertext.
VaultCrypto::encrypt(keys.enc.data(), plainBuf.data(), plainLen,
iv.data(), cipherBuf.data(), &cipherLen);
computeTag(keys, id, generation, iv.data(), cipherBuf.data(), cipherLen, tag);
// Persist [ver][iv][hmacTag][cipher].
const uint8_t ver = kCredFormatVersion;
f.write(&ver, kCredVerSize);
f.write(iv.data(), kIvSize);
f.write(tag.data(), kCredHmacTagSize);
f.write(cipherBuf.data(), cipherLen);
// Every transient buffer is wiped before this function returns.
VaultCrypto::secureWipe(cipherBuf.data(), cipherBuf.size());
VaultCrypto::secureWipe(iv.data(), iv.size());
VaultCrypto::secureWipe(tag.data(), tag.size());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”.
Leer un registro: verificar, luego descifrar
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:
// 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 (fileSize < kCredMinFileSize || fileSize > kCredMaxFileSize) return false;
const size_t cipherLen = fileSize - kCredHeaderSize;
if ((cipherLen % kAesBlockSize) != 0) return false;
// 2. Read [ver][iv][storedTag][cipher]; reject an unknown format version.
// ... (reads omitted) ...
if (ver != kCredFormatVersion) return false;
// 3. Verify-before-decrypt: recompute the tag and compare in CONSTANT TIME.
computeTag(keys, id, generation, iv.data(), cipherBuf.data(), cipherLen, expectedTag);
if (!VaultCrypto::constantTimeEqual(expectedTag.data(), storedTag.data(),
kCredHmacTagSize)) {
// MAC mismatch → tampered or corrupt. Wipe scratch, produce no plaintext.
return false;
}
// 4. Tag verified — only now is it safe to decrypt.
VaultCrypto::decrypt(keys.enc.data(), iv.data(), cipherBuf.data(),
cipherLen, plainBuf.data(), &plainLen);
// 5. Decode the packed plaintext with a strict, fail-closed codec.
return decodeCredential(plainBuf.data(), plainLen, out);Dos detalles hacen esto seguro, en lugar de meramente secuencial.
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 mediblemente 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:
// 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 (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 true con un registro completo, o false sin nada.
Cada camino de rechazo no produce texto plano — la lectura devuelve una credencial completa o nada en absoluto.
| Comprobación | Si pasa | Si falla |
|---|---|---|
| Tamaño y alineamiento | Versión conocida | Bloqueo ante fallos |
| Versión conocida | El tag del MAC coincide | Bloqueo ante fallos |
| El tag del MAC coincide | Descifra | Bloqueo ante fallos |
| Descifra | El registro decodifica | Bloqueo ante fallos |
| El registro decodifica | Devuelve la credencial | Bloqueo ante fallos |
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):
| Ataque (escritura en bruto de flash, sin PIN) | Por qué funcionaba |
|---|---|
Sustitución entre slots — copiar cred_05.bin sobre cred_03.bin | 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 TOTP | Misma 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 slot | Nada 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 |
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 7 bytes está centralizado en un único sitio, así que cada módulo de registro — y el camino de re-cifrado de claves — lo calcula bit a bit idéntico:
// ver(1) ‖ recordType(1) ‖ slotId(1) ‖ generation(4, little-endian)
inline size_t writeEnvelopeContext(uint8_t* out, EnvelopeRecordType type,
uint8_t slot, uint32_t generation) {
out[0] = kEnvelopeVersion; // 0x01 — unchanged on disk
out[1] = static_cast<uint8_t>(type); // Credential / Totp / Index
out[2] = slot; // the slot this file belongs to
out[3] = generation & 0xFF; // per-slot freshness counter,
out[4] = (generation >> 8) & 0xFF; // little-endian
out[5] = (generation >> 16) & 0xFF;
out[6] = (generation >> 24) & 0xFF;
return kEnvelopeContextSize; // 7
}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 — la misma puerta de verificar antes de descifrar, que cubre tres promesas más:
| Campo vinculado | Movimiento entre slots | Cambio entre tipos | Rollback selectivo |
|---|---|---|---|
| recordType Credential / Totp / Index | — | Cierra | — |
| slotId a qué slot pertenece el fichero | Cierra | — | — |
| generation frescura por slot | — | — | Cierra |
recordType y slotId cierran de raíz la confusión de tipos y la reubicación; generation cierra el rollback selectivo — un registro revertido mientras el resto de la bóveda avanza.
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: la pequeña cabecera que el dispositivo lee primero, que contiene los salts, el verificador de PIN y una tabla de generation por slot — todo sellado bajo su propio tag HMAC.
- magic · 2 B · @0 — "KV"
- ver · 1 B · @2 — 0x02
- kdfSalt · 16 B · @3
- pinVerifier · 32 B · @19 — HMAC
- hmacSalt · 16 B · @51
- genTable · … · @67+ — u32 / slot
- metaTag · 32 B · @~95 — HMAC, último
| Campo | Offset | Tamaño |
|---|---|---|
| magic | @0 | 2 B |
| ver | @2 | 1 B |
| kdfSalt | @3 | 16 B |
| pinVerifier | @19 | 32 B |
| hmacSalt | @51 | 16 B |
| genTable | @67+ | … |
| metaTag | @~95 | 32 B |
A diferencia de index.bin, meta.bin nunca se reconstruye a partir de los ficheros de registro — es la raíz de confianza. El tag final autentica cada byte que lo precede, incluida la tabla de generation.
Como los metadatos están autenticados, la secuencia de desbloqueo se convierte en una escalera cuidadosa: nada del fichero se confía hasta que el tag verifica, y un PIN equivocado deriva una clave maestra distinta — así que el tag de los metadatos y el verificador de PIN fallan ambos a la vez.
- 1 parse meta.bin magic · ver · longitud
- 2 PBKDF2 PIN + kdfSalt
- 3 derive macKey + hmacSalt
- 4 verify metaTag tiempo constante
- 5 check pinVerifier puerta de desbloqueo
- 6 unlocked tabla de confianza
Un PIN equivocado deriva una clave maestra distinta, así que el tag de meta y el verificador de PIN no coinciden ninguno de los dos — el desbloqueo se bloquea ante el fallo antes de que se confíe siquiera en la tabla de generation o en los salts.
Un meta.bin antiguo o con formato desajustado — pongamos un formatVer 0x01 de una versión anterior — simplemente no se autentica, y la bóveda se re-aprovisiona. No hay migración ni doble lectura: un solo formato en flash, siempre. (El dispositivo no está en producción, así que no hay datos reales que migrar.)
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 reescribe 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:
- 1 STAGE registro → staging, gen N+1
- 2 COMMIT renombrado de meta.bin
- 3 PROMOTE staging → canónico
- 4 CLEANUP elimina el marcador
El renombrado de meta.bin es el único punto de linealización. Al reiniciar, la recuperación recalcula el tag del registro en staging a la generation actual: o bien verifica (termina el promote) o no (descarta el huérfano, conserva el valor antiguo).
En el siguiente arranque, recoverPendingMutation recalcula el tag del registro en staging a la meta.gen[type][slot] actual. Si verifica, el commit ocurrió (el corte cayó después del renombrado) → termina el promote. Si no, el commit nunca ocurrió → descarta el huérfano y conserva el registro canónico bajo la generation antigua. Ninguna ventana deja inservible un slot ni reabre en silencio el rollback, y cada ventana de corte tiene su propio test nativo. Como un delete también incrementa la generation, una entrada borrada no puede resucitarse repitiendo su fichero antiguo.
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, en los builds open (flasheables por desarrolladores), 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. Atrapar eso necesita estado anti-rollback que viva fuera de la flash reescribible, que es exactamente lo que añaden los builds endurecidos _secure.
| Variante de rollback | Builds open | Builds _secure |
|---|---|---|
Selectivo — un registro revertido, meta.bin avanza | Cerrado — falla el MAC vinculado a la generation | Cerrado |
Instantánea completa — meta.bin + cada registro revertidos juntos | No cerrado — todo el conjunto es autoconsistente | Cerrado — Secure Boot + Flash Encryption + anti-rollback de NVS |
Prefiero enunciar ese límite con claridad antes que sobrevenderlo. El contador de generation es defensa en profundidad para los builds no seguros frente al ataque selectivo, mucho más práctico; el salto temporal de toda la bóveda es un problema de hardware, resuelto en hardware en los builds que optan por quemar eFuses.
Dos claves a partir de una maestra: separación de claves
El sobre usa dos claves distintas — una para cifrar, otra para el MAC — y eso es deliberado (el mismo instinto que dice que la llave de casa, la del coche y la del buzón deberían ser tres llaves distintas). El argumento de seguridad de encrypt-then-MAC asume que las claves de cifrado y 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 sub-claves separadas a partir de una única clave maestra, cada una etiquetada con una etiqueta de separación de dominio distinta, siguiendo 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”:
// One PBKDF2-derived master key fans out into three single-purpose sub-keys.
// encKey = HMAC(masterKey, "vault-enc") -> AES-256-CBC key
// macKey = HMAC(masterKey, "vault-mac" || hmacSalt) -> file HMAC key
// pinVerifier = HMAC(masterKey, "vault-pin") -> unlock check
VaultCrypto::hmacSha256(masterKey.data(), masterKey.size(),
kEncKeyLabel, kEncLabelLen, out.enc.data());
deriveMacKey(masterKey, hmacSalt, out.mac); // label || per-vault hmacSaltUna clave maestra, tres sub-claves de un solo propósito — cada una derivada con una etiqueta HMAC distinta para que ninguna clave haga nunca dos trabajos.
La tercera sub-clave, el verificador de PIN, es cómo el dispositivo comprueba el PIN sin almacenarlo: el desbloqueo deriva la clave maestra del PIN introducido, calcula el verificador, y lo compara — de nuevo en tiempo constante — con el valor almacenado. Un PIN equivocado simplemente produce un verificador distinto, sin ninguna pista sobre cuán equivocado estaba. (El verificador está en la cabecera de meta.bin junto a los salts; 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:
| Sub-clave | Cifrar / descifrar ficheros | Autenticar un fichero (tag HMAC) | Verificar el PIN |
|---|---|---|---|
| encKey HMAC(master, "vault-enc") | Puede | No puede | No puede |
| macKey HMAC(master, "vault-mac" ‖ salt) | No puede | Puede | No puede |
| pinVerifier HMAC(master, "vault-pin") | No puede | No puede | Puede |
Tres claves, tres trabajos. Las etiquetas HMAC son la separación de dominio que hace cada clave usable para un solo propósito — la clave de cifrado nunca puede sustituir a la clave de MAC, y ninguna de las dos puede verificar el PIN.
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. Valida el padding PKCS#7 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:
// The last byte claims the pad length; fold every check into one mask.
uint8_t padByte = plainOut[cipherLen - 1];
uint8_t padInvalid = 0;
padInvalid |= (padByte == 0); // pad length 0 is illegal
padInvalid |= (padByte > kAesBlockSize); // pad length > block is illegal
for (size_t i = 0; i < kAesBlockSize; i++) {
uint8_t mask = (i < padByte) ? 0xFF : 0x00; // examine the whole block
padInvalid |= (plainOut[cipherLen - 1 - i] ^ padByte) & mask;
}
if (padInvalid != 0) { // one message for corruption AND bad padding
LOG_ERROR(kTag, "Decryption failed");
mbedtls_platform_zeroize(plainOut, cipherLen);
return false;
}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:
// 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 una razón concreta y aburrida: uniformidad de backend. El mismo código tiene que producir una salida byte a byte idéntica en dos entornos muy distintos — un build de host (donde corren los tests unitarios, contra la API legacy de Mbed TLS) y el dispositivo (donde la API PSA Crypto de Mbed TLS 4.0 es el único camino público). CBC, HMAC y una comparación verificada a mano están disponibles de forma trivial y son deterministas en ambos; apoyarse en las reglas de gestión de nonce de GCM a través de dos backends añade un riesgo más afilado que el que estoy evitando. 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.
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)
- Las reglas de backend y de nonce deben coincidir exactamente entre host y dispositivo
Dos primitivas, dos claves, orden explícito. Más piezas móviles, pero cada una es simple y determinista.
- Byte a byte idéntico en Mbed TLS legacy (host) y PSA (dispositivo)
- EtM es demostrablemente la composición segura
- Un IV aleatorio nuevo por escritura, autenticado por el tag
Una nota al pie honesta 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”. La bóveda mantiene deliberadamente la forma de calcular-y-comparar porque corre idéntica en ambos backends — y la comparación sigue siendo en tiempo constante. Es un cambio de una comodidad de PSA por uniformidad de backend, hecho con los ojos abiertos.
Listar sin descifrarlo todo: el índice O(1)
Hay un buen efecto secundario de tratar cada fichero como un sobre independiente (ese «O(1)» del título es jerga para «el coste no crece por mucho que tengas mil credenciales»). Listar la bóveda no significa descifrar cada credencial — eso sería lento y expondría innecesariamente cada secreto en RAM. En su lugar, un único index.bin cifrado, en exactamente el mismo sobre [ver][iv][hmacTag][cipher], cachea solo los metadatos no secretos que cada credencial necesita en una vista de lista: su id, nombre, usuario, marca y un par de flags — nunca la contraseña, la URL, las notas ni el secreto TOTP.
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.
Hay una cosa que este formato no resuelve, sin embargo. Todo él protege el fichero — pero la clave de cifrado 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 derivadas del PIN. Reutilizar una sola clave para el cifrador y para el MAC debilita la construcción y debe evitarse.