Error al descifrar AES: clave, IV, modo y relleno
Cuando el descifrado AES falla en tus logs, el mensaje que recibiste probablemente describe el problema equivocado. Cuatro fallas sin relación entre sí producen síntomas casi idénticos, y la más común de todas, una clave incorrecta, se anuncia como un error de relleno.
Este es el orden que más tiempo ahorra, partiendo de un descifrado CBC que lanza BadPaddingException:
- Los bytes de la clave difieren entre los dos lados. La causa más probable por amplio margen.
- La derivación de clave difiere: misma frase de contraseña, distinto KDF o distinto número de iteraciones, y por tanto distintos bytes de clave.
- El texto cifrado se dañó en tránsito: truncado, con el base64 estropeado, o pasado por una codificación de texto.
- El IV es incorrecto. Pasa de verdad, pero no lanza un error de relleno. Daña dieciséis bytes y se queda callado.
El orden es estructural. CBC verifica el relleno como último paso del descifrado, después de aplicar la clave y deshacer el encadenamiento, de modo que el relleno funciona como una suma de verificación de todo lo que viene antes y falla a gritos sin importar cuál de esas piezas se rompió. Abajo están las mediciones; si prefieres empezar por lo práctico, pega tu texto cifrado en la herramienta de descifrado AES y aplica la bisección de la sección 9.
Todo lo que sigue se midió en java 1.8.0_162, node v25.8.2 y openssl 3.6.2. Los valores por defecto cambian entre versiones, así que trata los números de versión como parte del resultado.
1. Empieza por lo que tu error realmente descarta
Un mensaje de falla de AES dice casi nada sobre la causa y mucho sobre lo que la causa no puede ser. Úsalo para eliminar ramas, no para elegir una. Los mensajes vienen en inglés en todos los runtimes y nadie los traduce; búscalos con el texto literal de la excepción aunque tu documentación esté en español. El vocabulario de alrededor va por partida doble: descifrar y desencriptar, relleno y padding, vector de inicialización e IV nombran lo mismo, y quien escribió el código del otro lado pudo elegir cualquiera de las dos formas.
| Lo que ves | Lo que descarta | Lo que sigue vivo |
|---|---|---|
BadPaddingException, bad decrypt, wrong final block length | GCM; un error puro de IV; una falla de decodificación | clave incorrecta, KDF incorrecto, texto cifrado truncado, bytes del IV consumidos como texto cifrado, modo que no coincide, esquema de relleno que no coincide |
GCM Authentication failed, Unsupported state or unable to authenticate data | relleno; cualquier teoría que implique salida parcial | clave incorrecta, nonce incorrecto, tag separado o mal ubicado, longitud de tag incorrecta, AAD que no coincide |
| Sin excepción, la salida es basura | todo modo autenticado | ECB, CTR, CBC que tuvo suerte, modo que no coincide, IV incorrecto |
BadPaddingException, bad decrypt, wrong final block length
El mismo evento en tres ecosistemas: Java, OpenSSL y .NET. Se dispara al final del descifrado CBC o ECB, cuando el último bloque de texto plano no termina con un patrón PKCS#7 válido.
Lo útil es la parte negativa: llegar hasta ahí significa que tu base64 o tu hex se decodificaron y que el conteo de bytes era un múltiplo de 16 distinto de cero, o sea que el transporte no destrozó los datos y no estás en GCM. wrong final block length es la excepción. Ahí el conteo no era múltiplo de 16, lo que apunta a truncamiento en vez de a la clave, así que salta a la sección 8.
GCM Authentication failed y compañía
GCM compara el tag antes de liberar un solo byte de texto plano, como exige NIST SP 800-38D. Eso lo vuelve honesto de una forma en que el error de relleno no lo es: algo en la tupla (clave, nonce, texto cifrado, datos autenticados adicionales, tag) no coincide con lo que usó quien cifró. No puede decirte cuál elemento, y nunca podrá, porque acotar eso queda deliberadamente fuera de lo que hace el algoritmo. La sección 6 cubre el elemento que más se rompe entre lenguajes: la posición del tag, no su valor.
Sin error, pero la salida es basura
El desenlace peligroso, porque un dashboard lo registra como éxito. CTR nunca lanza nada y ECB tampoco. CBC solo lanza cuando el patrón de bytes final no pasa la verificación de relleno, y con una clave incorrecta ese byte es prácticamente aleatorio, y aproximadamente un intento de cada 256 cae en 0x01 y valida. Un poco menos del 0.4% de los descifrados CBC con clave incorrecta «funcionan». Pero la basura tiene forma, y la forma nombra el error: las secciones 4 y 5 traen las dos huellas que conviene memorizar.
2. El error más engañoso de AES
Esta es la medición que reordena las prioridades de depuración de casi cualquiera. Clave 0123456789abcdef, IV de ceros, AES/CBC/PKCS5Padding, texto plano hello world, en java 1.8.0_162 con el proveedor SunJCE que trae el JDK:
| Escenario | Cambio | Resultado medido |
|---|---|---|
| A | Clave incorrecta por 1 byte (último carácter f → X) | lanza javax.crypto.BadPaddingException: Given final block not properly padded. El relleno en sí nunca estuvo mal formado; el error es completamente engañoso |
| B | Clave correcta, IV incorrecto por 1 byte | sin excepción, el texto plano hello world volvió como iello world. Solo se dañó el byte correspondiente del primer bloque |
| C | Clave correcta, descifrar el texto cifrado CBC con AES/ECB | funcionó en silencio, sin excepción. Un modo que no coincide no tiene por qué lanzar nada |
El escenario A desvía tardes enteras. El escenario C manda datos malos a producción.
Por qué una clave incorrecta produce un error de relleno
El relleno no tenía nada de malo. Quien cifró añadió cinco bytes 0x05 para llevar hello world hasta dieciséis, cifró ese bloque, y ahí sigue en tu texto cifrado, intacto.
La falla ocurre a la salida. El descifrado CBC ejecuta el cifrador de bloque en reversa, hace XOR de cada resultado con el bloque de texto cifrado anterior, y solo entonces lee la cola del bloque final para decidir cuántos bytes recortar. Con la clave equivocada el cifrador produce dieciséis bytes de ruido, y el ruido casi nunca termina con un patrón PKCS#7 válido. La librería reporta lo que vio, relleno inválido, lo cual es cierto e inútil.
Lee BadPaddingException como «el texto plano que reconstruí no termina como termina un texto plano rellenado». La razón más probable de que tu reconstrucción esté mal es la clave, y por eso una búsqueda de aes decrypt wrong key y una búsqueda de bad padding exception te dejan en los mismos hilos: los dos síntomas son un solo síntoma. Una nota de diseño ya que andas por aquí. Nunca expongas esa distinción a quien llama, porque distinguir «relleno inválido» de «relleno válido, contenido incorrecto» es de lo que se alimenta un ataque de oráculo de relleno (Vaudenay, EUROCRYPT 2002).
Qué hace GCM de otra manera
GCM invierte el orden y verifica el tag antes de producir texto plano, y no hay ventana en la que existan bytes parcialmente correctos. Una falla de GCM nunca te deja dudando si la salida es real, porque no hay salida. GCM tampoco tiene relleno, al ser un modo de contador por debajo, así que la longitud del texto cifrado es igual a la del texto plano. Un error de relleno en un sistema que creías GCM demuestra entonces que el sistema no es GCM, casi siempre una configuración que cayó de vuelta a CBC.
3. ¿Ambos lados usan los mismos bytes de clave?
AES no ve tu cadena de clave. Ve 16, 24 o 32 bytes. Dos sistemas pueden guardar material de clave idéntico en un archivo de configuración y aun así no coincidir, porque «idéntico» es una propiedad del texto, no de los bytes.
Las tres formas en que una cadena de clave se convierte en bytes
Entrega la cadena literal 0123456789abcdef a tres librerías distintas:
como hex -> 8 bytes (longitud de clave AES inválida)
como base64 -> 12 bytes (longitud de clave AES inválida)
como UTF-8 crudo -> 16 bytes (AES-128 válido)
Dieciséis caracteres, tres conteos de bytes. Es un caso desagradable justamente porque es válido bajo las tres lecturas: cada carácter está tanto en el alfabeto hex como en el de base64, y dieciséis caracteres es una longitud legal para ambos decodificadores, y nada falla al momento de parsear.
La guía de solución de problemas de firma inválida en JWT trae la matriz completa entre librerías de cómo cada ecosistema interpreta una cadena de secreto; la versión corta para AES es anotar en qué codificación está tu material de clave y que ambos lados decodifiquen explícitamente. La forma HMAC del mismo error muerde a los receptores de webhooks, y está cubierta en la guía de verificación de firmas de webhook.
AES es estricto: exactamente 16, 24 o 32 bytes
Aquí es donde AES se aparta de la primitiva que la mayoría de los desarrolladores conoce primero. HMAC acepta claves de cualquier longitud: RFC 2104 hashea cualquier cosa más larga que el tamaño de bloque y rellena con ceros cualquier cosa más corta, así que un generador HMAC toma un secreto de 7 bytes o de 700 sin quejarse. AES tiene exactamente tres longitudes de clave legales y rechaza todo lo demás antes de procesar un solo bloque.
Esa rigidez es un regalo, porque un error de longitud es la única falla de AES que nombra su propia causa en vez de esconderse detrás del relleno. Nuestra herramienta lo expresa así: Key must be 16, 24, or 32 bytes (AES-128/192/256). Las trampas que producen una longitud incorrecta:
- Un salto de línea final de
KEY=$(cat key.txt)oecho "$KEY". Usaprintfyecho -n. Un espacio final pegado desde la interfaz de un gestor de secretos hace lo mismo. - Un prefijo
0xcopiado de un depurador: treinta y cuatro caracteres que ya no son hex válido. - Caracteres no ASCII.
contraseñatiene 10 caracteres y 11 bytes en UTF-8: una frase de contraseña «de 32 caracteres» con una letra acentuada son 33 bytes.
SecretKeySpec y el charset por defecto de la plataforma
Java tiene una versión de esto que solo aparece después del despliegue. "my secret".getBytes() sin argumento usa el charset por defecto de la plataforma, que antes de JDK 18 venía de la propiedad file.encoding y por lo tanto del sistema operativo y la configuración regional de la máquina. Una laptop en UTF-8 y un contenedor en ANSI_X3.4-1968 producen bytes distintos para cualquier carácter no ASCII. JEP 400 hizo de UTF-8 el valor por defecto en JDK 18, lo que arregla el código nuevo y nada más.
// incorrecto: los bytes dependen de la máquina
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(), "AES");
// correcto: los bytes no dependen de nada
SecretKeySpec ks = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "AES");
Si tu código funciona en local, falla en el servidor con un error de relleno y la frase de contraseña contiene algo fuera de ASCII, revisa esto primero.
4. El IV: dónde va y cómo se ve uno incorrecto
Un aes iv mismatch es la falla que la gente sospecha primero y diagnostica al final, porque no se comporta como las demás. Es silenciosa y es local.
Un IV incorrecto daña exactamente un bloque
Mira otra vez el escenario B. Clave correcta, IV incorrecto por un byte:
hello world -> iello world
Sin excepción, un carácter. Escribe el paso de CBC para el primer bloque y queda a la vista: P1 = D(C1) XOR IV. El IV entra por XOR directo al primer bloque de texto plano y no toca nada más, así que voltear un bit del IV voltea el mismo bit del texto plano en la misma posición. Aquí h (0x68) se volvió i (0x69), es decir, el primer byte del IV se movió exactamente 0x01.
La huella es fácil de leer. En CBC, los primeros 16 bytes en basura y todo lo que sigue limpio significa que el IV está mal y la clave está bien. Si todos los bloques salen en basura, la mala es la clave. Esa sola observación separa las dos causas más comunes sin cambiar una línea de código, y la herramienta de descifrado AES muestra los bytes decodificados para que puedas leerlo directamente.
¿Y por qué no lanzó nada? hello world son 11 bytes, un solo bloque, y el relleno PKCS#7 vive en los bytes 11 al 15 de ese bloque. El byte del IV que cambió era el byte 0, así que la zona del relleno quedó intacta y validó. Daña un byte del IV en la posición 11 o posterior y obtendrás un error de relleno, que es otra ruta por la que el error de relleno te miente.
Tres convenciones de transmisión
No hay un estándar sobre dónde va el IV, solo tres costumbres que interoperan mal.
El IV antepuesto, iv || ciphertext, es la convención más común y la predeterminada en nuestras herramientas. Ambos lados deben ponerse de acuerdo en cuánto recortar: 16 bytes para CBC y CTR, 12 para GCM. El error espejo es un productor que antepone y un consumidor que no. Los primeros 16 bytes del «texto cifrado» son entonces el IV, cada bloque se desplaza, y obtienes un error de relleno.
Un campo aparte, {"iv": "...", "ciphertext": "..."}, es más limpio en principio y duplica los lugares donde una codificación puede discrepar, porque ahora el IV tiene su propia pregunta de base64 contra hex.
Una constante fija, normalmente todo ceros, se escribe a mano porque alguien necesitaba determinismo. Interopera a la perfección, que es justo lo que la vuelve peligrosa: en CBC un IV fijo filtra la igualdad entre registros, y en GCM reutilizar un nonce bajo una misma clave revela el XOR de los dos textos planos y puede exponer la subclave GHASH que autentica el tag. SP 800-38D es explícito sobre la unicidad.
La opción de texto cifrado en crudo de la herramienta, junto con un IV explícito, prueba las tres convenciones contra los mismos bytes en un minuto.
El IV de GCM es de 12 bytes, no de 16
Los equipos que adoptan GCM editando una ruta CBC existente arrastran el IV de 16 bytes, y el resultado falla sin dar una sola pista.
SP 800-38D estandariza un IV de 96 bits. Se permiten otras longitudes, pero no son simplemente «un IV más largo»: cuando el IV no tiene 96 bits, GCM deriva su bloque contador inicial pasando el IV por GHASH en lugar de usarlo directamente. Los mismos 16 bytes usados como nonce producen entonces un keystream y un tag completamente distintos de los que darían los primeros 12, y obtienes una falla de autenticación genérica. Si el texto cifrado vino de otro lado y estás adivinando la disposición, cuenta hacia atrás: el tag son los últimos 16 bytes, y el nonce casi siempre los primeros 12.
5. Modo que no coincide, incluida la variante silenciosa
Cipher.getInstance("AES") es ECB
Java te deja nombrar un cifrador sin nombrar un modo ni un esquema de relleno. No se niega y no avisa. Con el proveedor SunJCE que trae el JDK, llena los huecos con ECB y PKCS5Padding.
Demostrarlo requiere el experimento correcto: cifra 32 bytes idénticos (dos bloques de A) con la clave 0123456789abcdef, y luego comprueba si los dos bloques de texto cifrado coinciden. En java 1.8.0_162:
getInstance("AES") ciphertext = 3bfd04cc0d7ed55358e2cbe19de213833bfd04cc0d7ed55358e2cbe19de21383377222e061a924c591cd9c27ea163ed4
block1 = 3bfd04cc0d7ed55358e2cbe19de21383
block2 = 3bfd04cc0d7ed55358e2cbe19de21383 <- bloques idénticos = la huella de ECB (la estructura del texto plano se filtra)
getInstance("AES/CBC/PKCS5Padding") los bloques difieren = el encadenamiento está activo
Idénticos byte por byte. Esa es la firma de ECB, la misma propiedad que hace que la famosa imagen del pingüino cifrado siga pareciendo un pingüino. El experimento solo funciona con bloques de texto plano idénticos: dieciséis bytes A seguidos de dieciséis bytes B producen dos bloques de texto cifrado distintos también bajo ECB, y concluirías erróneamente que el valor por defecto era CBC.
Acota bien el resultado: lo de arriba describe el proveedor SunJCE que trae el JDK en esa versión. La transformación por defecto es una decisión del proveedor, y un tercero como BouncyCastle puede resolver la misma abreviatura de otra manera. La generalización no es «Java significa ECB» sino «una cadena de transformación sin calificar significa lo que decida tu proveedor, y por eso nunca escribes una».
El modo equivocado puede no lanzar ningún error
El escenario C descifró texto cifrado CBC con AES/ECB y devolvió el texto plano correcto sin excepción. Parece imposible hasta que escribes la aritmética. El cifrado CBC del primer bloque es C1 = E(P1 XOR IV), y el descifrado ECB de ese bloque es D(C1) = P1 XOR IV. Aquí el IV era todo ceros, de modo que P1 XOR 0 = P1 y el primer bloque se descifra perfectamente. hello world mide un bloque, así que «el primer bloque» era el mensaje entero.
Quédate con la regla general: con un IV de ceros, ECB y CBC coinciden en el primer bloque y difieren en todos los que siguen. Descifra como ECB un mensaje CBC largo y obtienes dieciséis bytes limpios seguidos de ruido, el inverso exacto de la huella del IV incorrecto. Son dos formas opuestas de basura y ninguna de las dos viene con mensaje de error. Los IV de ceros escritos a mano son lo bastante comunes como para que esto no sea una curiosidad de laboratorio.
Qué te da una llamada mínima en cada lenguaje
| Ecosistema | Llamada mínima | Modo que realmente obtienes |
|---|---|---|
| Java (SunJCE) | Cipher.getInstance("AES") | ECB con PKCS5Padding, en silencio |
Node crypto | createDecipheriv('aes-256-cbc', key, iv) | lo que diga la cadena del algoritmo; no existe un valor por defecto |
| Web Crypto | crypto.subtle.decrypt({ name: 'AES-CBC', iv }, ...) | nombrado de forma explícita; ECB ni siquiera está implementado |
Python cryptography | Cipher(algorithms.AES(key), modes.CBC(iv)) | el objeto de modo es obligatorio |
| PyCryptodome | AES.new(key, AES.MODE_ECB) | argumento obligatorio, pero ECB está ahí mismo en el autocompletado |
Go crypto/aes | aes.NewCipher(key) devuelve un cipher.Block crudo | llamar a Decrypt sobre ese bloque es ECB; envuélvelo en cipher.NewCBCDecrypter o cipher.NewGCM |
| CryptoJS | CryptoJS.AES.decrypt(ct, "passphrase") | CBC, PKCS#7, EVP_BytesToKey con MD5 (ver sección 7) |
Los ecosistemas donde el modo vive en una cadena o en un objeto nunca te sorprenden. Los dos que ofrecen una llamada de «solo AES», Java y Go, son de donde salen los reportes de ECB accidental. Si el tuyo es uno de esos, vuelve a descifrar los mismos bytes cambiando de modo y mira cuál devuelve texto legible.
6. GCM: los mismos bytes, APIs distintas
La mayoría de las fallas de aes gcm auth tag entre lenguajes no son criptográficas. Ambos lados calcularon los mismos 16 bytes y no se ponen de acuerdo sobre dónde viven esos bytes.
La medición
Clave = 32 bytes 0123456789abcdef0123456789abcdef, IV = 12 bytes de ceros, texto plano hello world, en node v25.8.2 y java 1.8.0_162:
Node ciphertext = a616cd6d7d2328379d41e5 (11 B) <- update+final
authTag = c87af9f8ad7148e873fa797292c0af3f (16 B) <- se obtiene aparte con getAuthTag()
Java doFinal() = a616cd6d7d2328379d41e5c87af9f8ad7148e873fa797292c0af3f (27 B) <- texto cifrado y tag ya concatenados
Node ciphertext || authTag es exactamente Java doFinal(), los 27 bytes completos. Ninguna diferencia de codificación, nada que negociar: Node te entrega las dos piezas por separado y Java te las entrega pegadas. Nota también que 11 bytes de texto plano dieron 11 bytes de texto cifrado, porque GCM no añade relleno. Por eso un error de relleno jamás puede venir de una ruta GCM genuina.
Concatenado o separado, según el runtime
| Runtime | API de cifrado | Dónde termina el tag |
|---|---|---|
Node crypto | update() + final(), luego getAuthTag() | separado |
Java (SunJCE, AES/GCM/NoPadding) | doFinal() | anexado |
Go cipher.AEAD | Seal() | anexado |
Python cryptography, AESGCM | encrypt() | anexado |
Python cryptography, Cipher + modes.GCM | finalize(), luego encryptor.tag | separado |
| Web Crypto | crypto.subtle.encrypt | anexado |
Node es el bicho raro entre las APIs de alto nivel, y por eso Node hacia cualquier otra cosa es la dirección de falla más reportada. Antes de tocar código, comprueba dónde quedó el tag en tus propios bytes probando las dos disposiciones contra el mismo blob. Para empaquetar la salida de Node con destino a un consumidor Java, Go, Python o de navegador:
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const packed = Buffer.concat([ct, cipher.getAuthTag()]); // ahora coincide con doFinal()
Para desempaquetar un blob concatenado en Node:
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(packed.subarray(packed.length - 16)); // debe ir antes de final()
const pt = Buffer.concat([
decipher.update(packed.subarray(0, packed.length - 16)),
decipher.final(),
]);
La restricción de orden es real: llama a setAuthTag() después de final() y Node lanza Unsupported state or unable to authenticate data aunque cada byte sea correcto.
La longitud del tag es variable, y la unidad no
GCM permite tags de 128, 120, 112, 104 o 96 bits, con 64 y 32 reservados para aplicaciones con recursos limitados (SP 800-38D, Apéndice C). Casi todo el mundo usa 128, y el problema está en cómo lo pide cada API:
- Java:
new GCMParameterSpec(128, iv). El primer argumento está en bits. - Web Crypto:
{ name: 'AES-GCM', iv, tagLength: 128 }. También en bits, con 128 por defecto. - Node:
createCipheriv(algo, key, iv, { authTagLength: 16 }). En bytes.
new GCMParameterSpec(16, iv) es una línea de Java que se ve legal y pide un tag de 16 bits; algunos JDK la rechazan, y donde se acepta habrás cambiado tu garantía de integridad por una moneda al aire de uno en 65,536. Cuando los dos lados no coinciden en la longitud del tag, las longitudes empaquetadas tampoco coinciden: el receptor corta en el límite equivocado y obtiene una falla de autenticación que no tiene nada que ver con la clave.
7. Tienes una frase de contraseña, no una clave
Si cualquiera de los dos lados toma una cadena escrita por una persona, hay una función de derivación de clave entre esa cadena y AES, y una discrepancia de KDF es invisible. Nunca da error. Devuelve 32 bytes perfectamente buenos que resultan ser los 32 bytes equivocados, y la falla aflora una capa más abajo como (ya sabes cuál) un error de relleno.
PBKDF2 necesita que cuatro cosas coincidan
- Salt. En el formato
Salted__de OpenSSL son 8 bytes dentro del texto cifrado; en el formato de frase de contraseña de nuestras herramientas es un prefijo de 16 bytes; en los esquemas caseros suele ser una constante escrita a mano. - Iteraciones.
openssl enc -pbkdf2usa 10,000 por defecto. OWASP recomienda actualmente 600,000 para PBKDF2-HMAC-SHA256, que es lo que usa nuestro modo de frase de contraseña. Los frameworks eligen sus propios números. - Hash. SHA-1 contra SHA-256 contra SHA-512. El código viejo y algunos SDK móviles todavía usan SHA-1 por defecto.
- Longitud de salida. Treinta y dos bytes para AES-256, dieciséis para AES-128. Algunos esquemas derivan la clave y el IV juntos en una sola llamada más larga, lo que nunca coincide con una derivación simple de 32 bytes.
EVP_BytesToKey, y por qué CryptoJS sigue sin funcionar
cryptojs aes decrypt not working suele ser una discrepancia muy concreta. CryptoJS.AES.encrypt(text, "passphrase") no usa PBKDF2. Usa EVP_BytesToKey, la derivación de OpenSSL anterior a 1.1, con MD5 y una sola iteración.
EVP_BytesToKey además hace algo que PBKDF2 no hace: deriva la clave y el IV a partir de la frase de contraseña y la salt en una sola pasada. Por eso un archivo Salted__ de OpenSSL no lleva un campo de IV aparte, y por eso reproducir la salida de CryptoJS con PBKDF2 más un IV aleatorio está mal por partida doble.
El formato se reconoce a simple vista: los 8 bytes ASCII Salted__ seguidos de una salt de 8 bytes, codificados en base64, siempre empiezan con U2FsdGVkX1. Si tu texto cifrado empieza así, viene de una frase de contraseña y necesitas saber qué derivación se usó; la herramienta de descifrado AES detecta el prefijo y cambia entre las tres sin tocar código.
Por qué la misma contraseña da claves distintas
No existe tal cosa como «la contraseña de AES». Cada librería inventó su propio camino de cadena a clave:
| Productor | Derivación | Resultado para una frase de contraseña |
|---|---|---|
CryptoJS AES.encrypt(text, pass) | EVP_BytesToKey, MD5, 1 iteración | clave A |
openssl enc 1.0.2 y anteriores | EVP_BytesToKey, MD5, 1 iteración | clave A |
openssl enc 1.1+ sin -pbkdf2 | EVP_BytesToKey, SHA-256, 1 iteración | clave B |
openssl enc -pbkdf2 | PBKDF2-HMAC-SHA256, 10,000 iteraciones | clave C |
| Nuestro modo de frase de contraseña | PBKDF2-HMAC-SHA256, 600,000 iteraciones | clave D |
| Java, Python, Go | sin valor por defecto; tú escribes la derivación | lo que hayas escrito |
Cuatro claves de una sola contraseña antes de que nadie cometa un error. La actualización de 1.0.2 a 1.1 cambió el digest por defecto de MD5 a SHA-256, y por eso el texto cifrado de scripts viejos dejó de descifrarse con el mismo comando en una máquina más nueva. Si heredaste datos y nadie recuerda la cadena de herramientas, prueba las derivaciones en ese orden. Es una prueba de tres opciones, no una búsqueda.
8. Lo que el transporte le hizo a tus bytes
El texto cifrado es binario uniformemente aleatorio, lo que lo vuelve lo más hostil posible para cualquier cosa que trate los bytes como texto. Una buena parte de las fallas de AES nunca involucra al cifrador.
Variantes de Base64 y relleno faltante
El base64 estándar (RFC 4648 §4) usa + y /; la variante segura para URL (§5) usa - y _. Una cadena segura para URL entregada a un decodificador estándar o bien lanza una excepción o, en decodificadores permisivos, descarta en silencio los caracteres que le estorban y devuelve bytes cortos y desalineados, y por eso Java trae Base64.getUrlDecoder() y Base64.getDecoder() como objetos separados. Algunos codificadores además eliminan el = final, algunos decodificadores insisten en él, y las rutas de código cercanas a JWT lo quitan por defecto.
Antes de sospechar de la clave, decodifica el texto cifrado y compara su longitud contra el modo:
- CBC y ECB: un múltiplo de 16 distinto de cero. Cualquier otra cosa es truncamiento o un problema de decodificación, no un problema de clave.
- GCM: la longitud del texto cifrado es igual a la del texto plano, más 16 del tag, más 12 al frente si el nonce va antepuesto.
- CTR: cualquier longitud, así que esta comprobación no te dice nada.
El decodificador Base64 te da el conteo de bytes con un solo pegado, muchas veces la medición más rápida de toda la investigación.
Saltos de línea, comillas tipográficas y el viaje de ida y vuelta por UTF-8
openssl base64 corta la salida a 64 columnas a menos que pases -A, y algunos decodificadores se saltan los saltos de línea incrustados mientras otros los rechazan: el mismo archivo se decodifica en una máquina y falla en otra. Copiar a través de un cliente de chat o un editor de documentos convierte las comillas rectas en curvas y los guiones en rayas, y la diferencia es casi invisible en una terminal.
El irrecuperable es el viaje de ida y vuelta por UTF-8. Si la salida cruda de AES llega a guardarse como string sin codificarla primero (new String(cipherBytes) en Java, bytes.decode('utf-8', errors='replace') en Python, un TextDecoder en cualquier lado), toda secuencia de bytes que no sea UTF-8 válido colapsa a U+FFFD, y volver a codificarla te deja EF BF BD donde antes estaban tus datos. Como más o menos la mitad de los bytes aleatorios no son ASCII, la mayor parte del texto cifrado queda destruida y ninguna clave la recupera; la guía de codificación UTF-8 y UTF-16 explica por qué la pérdida es de un solo sentido. El texto cifrado binario viaja como base64, como hex o como binario, pero nunca como string.
Columnas de base de datos
El almacenamiento aplica el mismo daño de forma más discreta. El texto cifrado que se escribe en un VARCHAR(255) y queda un bloque más largo de la cuenta se corta, y MySQL fuera del modo estricto lo hace sin dar error. La cola es donde viven el bloque de relleno y el tag de GCM, con lo cual una fila escrita «con éxito» hace meses ahora falla, y si el corte cayó en un límite de 16 bytes la comprobación de longitud de arriba tampoco lo detectará. La conversión de charset hace el resto: una columna latin1 que recibe bytes UTF-8 reescribe tus datos a la entrada.
Guarda el texto cifrado en VARBINARY, BLOB o bytea, o guarda base64 en una columna de texto con espacio de sobra.
9. Un flujo de bisección que lo encuentra en cinco minutos
Cada sección de arriba acota una variable. Si las recorres en orden contra una implementación de referencia que tú controlas, converges rápido, y las herramientas del navegador sirven bien como esa referencia porque todo el cálculo ocurre en tu propia pestaña, la clave y el texto cifrado nunca salen de la página, y puedes cambiar un ajuste a la vez y ver los bytes.
-
Paso 0: mide la forma. Decodifica el texto cifrado y anota el conteo de bytes, los primeros bytes y si empieza con
U2FsdGVkX1. Compara el conteo contra la sección 8. Si no es múltiplo de 16 y crees estar en CBC, detente: esto es un error de transporte. -
Paso 1: cifra un texto plano conocido. En la herramienta de cifrado AES, cifra una cadena corta y conocida con los parámetros que crees que usa producción, y luego compara la forma de las dos salidas en vez de sus valores: longitud total, bytes de prefijo, presencia de un encabezado de salt. Una discrepancia significa que tu suposición sobre el formato o el KDF está mal, y por más que juegues con la clave no lo vas a arreglar.
-
Paso 2: recorre las derivaciones. Para datos derivados de una frase de contraseña, ejecuta PBKDF2 con el número exacto de iteraciones, luego EVP-SHA256 y luego EVP-MD5 en la herramienta de descifrado AES. Exactamente una puede ser la correcta. Si ninguna funciona, el error está por encima del KDF.
-
Paso 3: quita toda convención. Cambia a una clave cruda, activa el texto cifrado en crudo y proporciona el IV de forma explícita. Ahora estás declarando exactamente qué bytes son clave, IV y texto cifrado, sin nada inferido. Si descifra aquí pero no en tu código, tu error es de empaquetado (un prefijo de IV que no se recortó, un tag en el lugar equivocado) y no criptográfico.
-
Paso 4: cambia el modo. Prueba CBC, luego CTR, luego GCM contra los mismos bytes. Que CTR devuelva texto legible donde CBC falló es un modo que no coincide, punto.
-
Paso 5: lee la basura. Primer bloque malo y el resto limpio significa el IV. Primer bloque limpio y el resto malo significa que descifraste CBC como ECB con un IV de ceros. Todo malo significa la clave o la derivación.
10. Preguntas frecuentes
¿Por qué mi código AES funciona en local y falla en producción?
Tu código AES funciona en local y falla en producción porque el entorno cambió algo que no está en el control de versiones. Sospechosos habituales, en orden: la clave llegó de una variable de entorno o de un gestor de secretos con un salto de línea final; el charset por defecto de la plataforma en Java difiere entre la laptop y el contenedor, así que getBytes() produjo bytes distintos (sección 3); el OpenSSL de producción es 1.1+ mientras que tus scripts locales apuntaban a 1.0.2, lo que cambia el digest de EVP_BytesToKey de MD5 a SHA-256; o una columna de base de datos trunca el texto cifrado solo en un entorno. Imprime primero la longitud de la clave y la del texto cifrado en bytes en ambos lados, porque esos dos números suelen zanjar el asunto.
Cifré en Node y no puedo descifrar en Java. ¿Por dónde empiezo?
De Node a Java, empieza por el tag de GCM, la causa más común y menos obvia. Node devuelve el texto cifrado y el tag por separado; el doFinal() de Java los espera concatenados como ciphertext || tag, y la sección 6 muestra que los bytes son idénticos en todo lo demás. Si estás en CBC, empieza por la convención del IV: ¿Node lo antepuso, y el lado Java recorta 16 bytes antes de descifrar? En tercer lugar está la clave misma, donde Buffer.from(k, 'hex') y k.getBytes(StandardCharsets.UTF_8) producen longitudes distintas a partir de la misma cadena.
¿El PKCS5Padding de Java es lo mismo que PKCS#7?
Para AES, el PKCS5Padding de Java y PKCS#7 son lo mismo en la práctica. PKCS#5 (RFC 8018) está definido solo para bloques de 8 bytes; PKCS#7 (RFC 5652) generaliza el esquema a tamaños de bloque de 1 a 255 bytes. El PKCS5Padding de Java aplicado a un cifrador de bloque de 16 bytes implementa el comportamiento de PKCS#7, y el nombre es un resabio histórico. Este nunca es tu error. NoPadding sí lo es: exige un texto plano que ya sea múltiplo de 16, y al descifrar te devuelve el relleno como datos, con lo que ves texto plausible con bytes finales como \x05\x05\x05\x05\x05.
Mi clave tiene 32 caracteres pero AES dice que la longitud es inválida. ¿Por qué?
Un error de longitud significa que la librería recibió un conteo de bytes que no es 16, 24 ni 32. Con una cadena de 32 caracteres eso suele ser un salto de línea final (33 bytes), un prefijo 0x que vuelve la cadena hex inválida, o un carácter no ASCII que ocupa dos o tres bytes en UTF-8. La variante más peligrosa es no recibir ningún error: 32 caracteres hex decodifican a 16 bytes válidos y 32 caracteres base64 decodifican a 24 bytes válidos, ambas longitudes legales de AES. La librería los acepta, usa la clave equivocada y te entrega una falla de relleno. Revisa el conteo de bytes, no el de caracteres.
El descifrado «funcionó» pero la salida es basura. ¿Qué salió mal?
El descifrado «funcionó» y la salida es basura porque estás en un modo que no verifica nada. CTR y ECB nunca lanzan nada, y CBC solo lanza cuando el patrón de bytes final no pasa la verificación de relleno, cosa que una clave incorrecta consigue un poco menos del 0.4% de las veces. Lee la forma: primeros 16 bytes corruptos y el resto limpio significa el IV; primeros 16 limpios y el resto corrupto significa que descifraste texto cifrado CBC como ECB con un IV de ceros; todo corrupto por igual significa la clave o la derivación. Texto legible con unos cuantos bytes finales raros significa NoPadding sobre datos rellenados. El arreglo de fondo es GCM, para que «funcionó» signifique algo.
¿Puedo descifrar si perdí el IV?
Sin el IV todavía puedes descifrar en CBC todo menos los primeros 16 bytes. Los bloques del 2 en adelante se recuperan como D(C_i) XOR C_{i-1}, y toda entrada de esa operación ya está en el texto cifrado: solo el primer bloque necesita el IV. Si además sabes cómo empieza el texto plano, digamos un blob JSON que empieza con {"userId":, puedes recuperar el IV directamente como D(C1) XOR P1. En CTR el IV siembra todo el keystream, así que perderlo lo pierde todo. En GCM el nonce alimenta tanto al contador como al tag, y no hay recuperación parcial.
¿Puedo recuperar el texto plano si el tag de GCM se truncó o se perdió?
Con el tag de GCM truncado o perdido, matemáticamente sí; en la práctica, con esfuerzo. GCM es modo CTR por debajo, así que la clave y el nonce por sí solos reproducen el keystream. Ninguna librería mayoritaria lo hará por ti: Java, Go, Python y Web Crypto se niegan por diseño a liberar texto plano sin un tag válido. La alternativa es descifrar los mismos bytes como AES-CTR con el bloque contador inicial puesto en el nonce de 12 bytes seguido de 00000002, que es donde empieza el primer bloque de datos de GCM. Recuperas los datos y renuncias a toda garantía de integridad: trata el resultado como no confiable. Si todavía tienes los 16 bytes del tag y la autenticación falla de todas formas, el tag no falta y tu error es alguna otra cosa de esta página. Llévalo a la herramienta de descifrado AES y empieza en el paso 0.