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

> Una entrada de jmrp.io, publicada como documento propio. Índice: https://jmrp.io/llms-full.txt

Canonical: https://jmrp.io/es/blog/011-encrypt-then-mac-vault/
Language: es
Alternate: https://jmrp.io/blog/011-encrypt-then-mac-vault/index.md
License: https://creativecommons.org/licenses/by/4.0/
Type: TechArticle
Published: 2026-06-16
Updated: 2026-08-28
Instructions re-tested: 2026-08-22 · ESP-IDF 6.0.2 · Mbed TLS 4.0
Author: José Manuel Requena Plens
Summary: 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.
Tags: Security, Cryptography, Firmware, ESP32, C++
Topics: Authenticated encryption (Q15263584), HMAC (Q1669397), Advanced Encryption Standard (Q190746), Padding oracle attack (Q7123320), ESP32 (Q27921668), PBKDF2 (Q3952834)
Build-Date: 2026-09-17

Preguntas que responde:

**¿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.

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

Por uniformidad de backend, no por preferencia criptográfica: en un protocolo nuevo GCM o ChaCha20-Poly1305 serían la elección obvia. Con la misma clave, IV y texto plano — como en los vectores de prueba fijos — el mismo código debe producir una salida byte a byte idéntica en la compilación de host contra la API antigua de Mbed TLS y en el dispositivo contra la API PSA Crypto de Mbed TLS 4.0. (Las escrituras reales usan un IV aleatorio nuevo, así que sus textos cifrados difieren por diseño.) CBC, HMAC y una comparación en tiempo constante son deterministas en ambos; las reglas de gestión de nonces de GCM entre dos backends son un riesgo mayor. 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?**

Un único index.bin cifrado, en el mismo sobre [ver][iv][hmacTag][cipher], guarda solo los metadatos no secretos que necesita una vista de lista: id, nombre, usuario, marca y un par de flags. Nunca contiene contraseñas, URLs, notas ni secretos TOTP, así que listar la bóveda cuesta un solo descifrado con independencia de cuántas credenciales haya, y no expone ningún secreto en RAM solo para pintar una lista.


---

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](https://en.wikipedia.org/wiki/Padding_oracle_attack). 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](https://en.wikipedia.org/wiki/Threat_model) 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](https://datatracker.ietf.org/doc/html/rfc5652#section-6.3) 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](https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Cipher_block_chaining_\(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_:

**Cómo un oráculo de padding saca un byte**

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.
   En CBC, el bloque anterior se mezcla (con un XOR) en el descifrado del siguiente, así que ese byte controla lo que sale.
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.

### De Lucky Thirteen al Doom Principle

Ese es el [ataque de oráculo de padding](https://www.iacr.org/archive/eurocrypt2002/23320530/cbc02_e02d.pdf), descrito por primera vez por Serge Vaudenay en 2002, y lleva dos décadas rompiendo protocolos reales: [Lucky Thirteen](https://www.ieee-security.org/TC/SP2013/papers/4977a526.pdf) contra TLS en 2013, [POODLE](https://openssl-library.org/files/ssl-poodle.pdf) contra SSL 3.0 en 2014.

Moxie Marlinspike destiló la lección en [The Cryptographic Doom Principle](https://moxie.org/2011/12/13/the-cryptographic-doom-principle.html): _"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.

**Advertencia — El orden lo es todo**

La versión peligrosa y la versión segura usan las _mismas primitivas_ — AES-CBC, HMAC-SHA256, una comparación en tiempo constante. La única diferencia es **qué ocurre antes de qué**. Una bóveda que descifra un fichero no autenticado ya ha perdido, por muy fuerte que sea el cifrador.

## 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_](https://eprint.iacr.org/2000/025) ([J. Cryptology, 2008](https://doi.org/10.1007/s00145-008-9026-x)), reforzado para canales seguros por el de Hugo Krawczyk, [_The Order of Encryption and Authentication for Protecting Communications_](https://eprint.iacr.org/2001/045).

### Las tres composiciones genéricas

**Las tres composiciones genéricas**

| 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_:

### Mismas primitivas, resultados opuestos

**MAC-then-Encrypt (el camino de la perdición) — debe descifrar antes de poder comprobar**

1. Recibe un fichero posiblemente manipulado
2. **inseguro — bytes del atacante tocados antes de la comprobación:** Descífralo — toca bytes del atacante y quita el padding PKCS#7 (aquí es donde el oráculo filtra información)
3. **inseguro — bytes del atacante tocados antes de la comprobación:** Solo ahora comprueba el MAC — ya demasiado tarde

**Encrypt-then-MAC (la bóveda) — comprueba antes de descifrar**

1. Recibe un fichero posiblemente manipulado
2. **seguro — verificado antes de cualquier descifrado:** Recalcula y comprueba primero el MAC sobre el texto cifrado (no hace falta descifrar para esto)
3. **seguro — verificado antes de cualquier descifrado:** Descifra solo si el tag verificó

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: 49 bytes de cabecera antes del texto cifrado AES-256-CBC

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:

**El sobre del fichero de bóveda**

| Offset | Campo | Tamaño | Nota |
| ---: | --- | ---: | --- |
| 0 | `ver` | 1 B | versión del formato |
| 1 | `IV` | 16 B | aleatorio por escritura |
| 17 | `hmacTag` | 32 B | HMAC-SHA256 |
| 49 | `cipher` | variable | AES-256-CBC |

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](https://datatracker.ietf.org/doc/html/rfc2104) al conjunto (sobre [SHA-256](https://csrc.nist.gov/pubs/fips/180-4/upd1/final)) con la sub-clave de MAC del fichero:

**vault_envelope.h — el mensaje autenticado**

```cpp
// 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](#qué-vincula-realmente-el-tag-slot-tipo-y-frescura); 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](#defensa-en-profundidad-padding-en-tiempo-constante--un-códec-fail-closed)), 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:

**save() — el camino de escritura encrypt-then-MAC (recortado)**

```cpp
// 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`](https://mbed-tls.readthedocs.io/projects/api/en/development/api/file/platform__util_8h/) 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:

**Consejo — La regla de oro, en una frase**

Comprobar el sello antes de abrir el sobre. Si el tag no cuadra, el fichero se rechaza **sin descifrar ni un solo byte** — así el atacante nunca consigue que el dispositivo "reaccione" a sus bytes manipulados, que era justo lo que alimentaba el oráculo de padding.

**load() — 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 (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

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 2^256 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`](https://mbed-tls.readthedocs.io/projects/api/en/development/api/file/constant__time_8h/) 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_](https://web.archive.org/web/20231229151151/https://codahale.com/a-lesson-in-timing-attacks/) de Coda Hale es la clásica explicación de cinco minutos; las [notas sobre tiempo constante de BearSSL](https://www.bearssl.org/constanttime.html) 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 `true` con un registro completo, o `false` sin nada.

5 comprobaciones en orden; cualquier fallo se bloquea ante el fallo, y si todas pasan se devuelve el resultado.

1. **Tamaño y alineamiento** — ¿alineado a bloque?
2. **Versión conocida** — byte de formato
3. **El tag del MAC coincide** — tiempo constante
4. **Descifra** — ¿padding válido?
5. **El registro decodifica** — con límites comprobados

- Si pasa: **Devuelve la credencial**
- Si falla (cualquier fallo): **Bloqueo ante fallos** — borra scratch · sin texto plano

Cada camino de rechazo no produce texto plano — la lectura devuelve una credencial completa o nada en absoluto.

## 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` | 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 |

### 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](https://datatracker.ietf.org/doc/html/rfc5116#section-2.1) 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:

**vault_envelope.h — writeEnvelopeContext (un prefijo centralizado)**

```cpp
// 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:

**Qué derrota la vinculación de cada campo de contexto**

| 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.

**meta.bin — metadatos autenticados (formatVer 0x02)**

| Offset | Campo | Tamaño | Nota |
| ---: | --- | ---: | --- |
| 0 | `magic` | 2 B | "KV" |
| 2 | `ver` | 1 B | 0x02 |
| 3 | `kdfSalt` | 16 B |  |
| 19 | `pinVerifier` | 32 B | HMAC |
| 51 | `hmacSalt` | 16 B |  |
| 67 | `genTable` | variable | u32 / slot |
| ~95 | `metaTag` | 32 B | HMAC, último |

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.

**Desbloqueo: no confíes en nada hasta que el tag verifique**

1. `parse meta.bin` — magic · ver · longitud
2. `PBKDF2` — PIN + kdfSalt (vía: sin confianza aún)
3. `derive macKey` — + hmacSalt
4. `verify metaTag` — tiempo constante (vía: puerta)
5. `check pinVerifier` — puerta de desbloqueo (vía: ok)
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.)

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

El rollback selectivo es un [ataque de repetición](https://datatracker.ietf.org/doc/html/rfc4949) — 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](https://pubs.opengroup.org/onlinepubs/9699919799/functions/rename.html) 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:

**Mutación resistente a cortes — el intercambio de meta es el punto de commit**

1. `STAGE` — registro → staging, gen N+1
2. `COMMIT` — renombrado de meta.bin (vía: punto de commit)
3. `PROMOTE` — staging → canónico (vía: tras el commit)
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`.

**Qué rollback atrapa cada build**

| 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.

## ¿Necesita encrypt-then-MAC claves separadas?

Sí — y el sobre lo hace de forma deliberada: una clave para cifrar y otra para el MAC, ambas derivadas de la misma clave maestra que sale del PIN (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](https://csrc.nist.gov/pubs/sp/800/57/pt1/r5/final) de toda la vida — el [NIST SP 800-57 Part 1](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-57pt1r5.pdf) lo dice sin rodeos: "una clave debería usarse para un solo propósito":

**vault_keys — etiquetas distintas por propósito**

```cpp
// 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 hmacSalt
```

- `masterKey` (derivada por PBKDF2)
- se divide en: `encKey` (HMAC(·, "vault-enc")) + `macKey` (HMAC(·, "vault-mac" ‖ salt)) + `pinVerifier` (HMAC(·, "vault-pin"))

Una 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.

### ¿Cómo se comprueba un PIN sin almacenarlo?

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:

**Qué puede — y qué no — hacer cada sub-clave**

| 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](https://datatracker.ietf.org/doc/html/rfc5652#section-6.3) 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:

**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.
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:

**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;
}
```

**Idea clave — Autenticado, pero aún parseado defensivamente**

El tag EtM ya demuestra que el texto plano es genuino antes de que el códec se ejecute, así que estas comprobaciones guardan una superficie pequeña y autenticada. Se quedan igualmente: la defensa en profundidad apenas cuesta nada aquí, y una página de flash corrupta o un futuro bug no deberían poder convertir bytes decodificados en un desbordamiento de buffer. Este es el mismo instinto que hay detrás de un formato de bóveda cifrado del mundo real como el [KDBX de KeePass](https://keepass.info/help/kb/kdbx.html), cuya [revisión KDBX 4](https://keepass.info/help/kb/kdbx_4.html) autentica su cabecera y su payload con HMAC-SHA256 en lugar de confiar en el contenedor.

## ¿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](https://en.wikipedia.org/wiki/Authenticated_encryption) como [AES-GCM](https://csrc.nist.gov/pubs/sp/800/38/d/final), 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](https://eprint.iacr.org/2000/025), 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.

**Comparación: AES-GCM (AEAD) vs AES-CBC + HMAC (EtM)**

**AES-GCM (AEAD)**

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

**Compromisos de AES-GCM**

- **A favor** — Estándar moderno por defecto; resistente al mal uso si los nonces son únicos
- **Salvedad** — La reutilización de nonce es catastrófica (recuperación de clave)
- **Salvedad** — Las reglas de backend y de nonce deben coincidir exactamente entre host y dispositivo

**AES-CBC + HMAC (EtM)**

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

**Compromisos de AES-CBC más HMAC**

- **A favor** — Byte a byte idéntico en Mbed TLS legacy (host) y PSA (dispositivo)
- **A favor** — EtM es demostrablemente la composición segura
- **A favor** — 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.

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

Un único `index.bin` cifrado, en exactamente el mismo sobre `[ver][iv][hmacTag][cipher]`, cachea solo los metadatos _no secretos_ que necesita una vista de lista: id, nombre, usuario, marca y un par de flags — nunca la contraseña, la URL, las notas ni el secreto TOTP. Listar cuesta por tanto una lectura y un descifrado, fijos, por muchas credenciales que guarde la bóveda.

Eso sale de tratar cada fichero 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.

**Información — Una lectura, un descifrado**

Mostrar la lista de credenciales es **una lectura de fichero más un descifrado**, sin importar cuántas credenciales existan. El descifrado pesado por credencial solo ocurre cuando abres una de verdad. El índice es solo una caché: si falta o su MAC no verifica, se reconstruye a partir de los ficheros por credencial autenticados, que son siempre la fuente de verdad.

## 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.

**Calificación de seguridad: A (Muy bueno) — 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 son por diseño: el rollback de instantánea completa (cerrado solo en los builds _secure) 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 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](/es/blog/012-device-bound-key-derivation/).

