AES-GCM bien usado: nonce, tag y derivación de claves
Curso de seguridad 5: ECB que deja ver un dibujo, un nonce reutilizado que revela un mensaje, un tag de 1 byte que PHP aceptó y trampas de openssl enc.
Serie: curso de seguridad básica 5/6 (avanzado). Esta clase es para ti si te tocó guardar algo cifrado: las API keys de los clientes de un proyecto freelance, tokens, una columna con datos personales o un respaldo. No basta con saber qué llamada "funciona"; aquí vemos cuál es segura. Al terminar habrás roto ECB, un nonce reutilizado y CBC/CTR sin autenticación en tu propia computadora, y tendrás un patrón pequeño de contraseña → clave → AES-GCM que se abre igual desde Python y desde PHP.
El cifrado es la parte de la seguridad donde más código "funciona" y aun así está mal. Todos los ejemplos rotos de abajo descifran bien cuando los pruebas con tus propios datos. El problema aparece solo cuando alguien cambia un byte, ve dos textos cifrados o manda un tag corto.
Si trabajas con clientes en Europa o en América Latina, esto tiene una cara legal. El RGPD (GDPR) y las leyes de datos personales de la región, como la LGPD de Brasil o la ley mexicana, piden en términos generales medidas de seguridad adecuadas, y el cifrado es la medida que todos mencionan. Pero un cifrado que deja ver patrones, o que cualquiera puede modificar, difícilmente cuenta como "adecuado" cuando hay que explicar un incidente. (Esto no es asesoría legal; para casos concretos consulta la guía oficial de cada país y a un especialista.) Y si vienes de un bootcamp, es el tema que casi nunca entra en el temario: se enseña encrypt() y decrypt(), pero no qué pasa cuando alguien toca los bytes.
Todo se ejecutó en una Mac con Apple Silicon (M1 Pro) con PHP 8.5.6 (enlazado a OpenSSL 3.6.2 y libsodium 1.0.22), la CLI de OpenSSL 4.1.0, el Python 3.9.6 del sistema de macOS y Java 26. Las claves, los mensajes y los "ataques" son datos de demostración creados en local. Las rutas se acortaron a ~/demo/.
Si te saltaste las clases anteriores: en la clase 1 separamos hash, MAC y cifrado, y en la clase 4 usamos un MAC para firmar tokens. Aquí necesitamos las dos mitades: confidencialidad (nadie lo lee) e integridad (nadie lo cambia sin que se note).
Qué te da de verdad tu caja de herramientas
Antes de escribir código, comprueba qué existe. Esta tabla salió de probar cada línea.
| Herramienta (versión aquí) | AES-GCM | Otro AEAD | KDF para contraseñas |
|---|---|---|---|
| Python 3.9.6, biblioteca estándar | no hay (no hay AES) | no hay | solo hashlib.pbkdf2_hmac; falta hashlib.scrypt |
Python + cryptography 48.0.0 (paquete externo) |
AESGCM |
ChaCha20-Poly1305 | Scrypt, PBKDF2 |
PHP 8.5.6 openssl_* |
aes-256-gcm |
aparece aes-256-gcm-siv |
hash_pbkdf2 |
PHP 8.5.6 sodium_* |
sodium_crypto_aead_aes256gcm_* (disponible: true) |
XChaCha20-Poly1305 | Argon2id, scrypt |
Java 26 javax.crypto |
AES/GCM/NoPadding |
ChaCha20-Poly1305 | PBKDF2 |
CLI de OpenSSL 4.1.0 enc |
rechazado | rechazado | antigua por defecto, -pbkdf2 opcional |
Dos líneas nos sorprendieron. El Python del sistema de macOS está enlazado a LibreSSL 2.8.3, y ahí hashlib.scrypt simplemente no existe:
$ python3 -c "import hashlib; hashlib.scrypt(b'pw', salt=b's'*16, n=2**14, r=8, p=1)"
AttributeError: module 'hashlib' has no attribute 'scrypt'
Un Python de python.org o de Homebrew enlazado a OpenSSL sí lo tiene, así que el código que "en mi laptop funciona" puede fallar en la de otra persona del equipo. La otra sorpresa es la CLI, que vemos más abajo. El código AES en Python de esta clase usa el paquete cryptography, que ya estaba instalado en esta máquina. No es parte de la biblioteca estándar; en un proyecto lo agregas a tus dependencias.
ECB: el modo que dibuja tus datos
ECB (Electronic Codebook) cifra cada bloque de 16 bytes por separado con la misma clave. Bloques de texto plano iguales dan bloques cifrados iguales. Para verlo, armamos un "ícono" de 8×8 donde cada celda es un bloque de 16 bytes de 0xFF (oscuro) o 0x00 (claro), y contamos cuántos bloques cifrados distintos hay.
<?php
// ~/demo/ecb.php — ECB vs CBC vs GCM con datos que repiten bloques
$key = hex2bin('000102030405060708090a0b0c0d0e0f'); // clave fija solo para la demo
$icon = ["..####..", ".#....#.", "#.#..#.#", "#......#",
"#.#..#.#", "#..##..#", ".#....#.", "..####.."];
$pt = '';
foreach ($icon as $row) foreach (str_split($row) as $c)
$pt .= str_repeat($c === '#' ? "\xFF" : "\x00", 16);
function show(string $name, string $ct): void {
$blocks = str_split(substr($ct, 0, 1024), 16);
$uniq = array_values(array_unique($blocks));
echo "$name: unique ciphertext blocks = ", count($uniq), " / 64\n";
if (count($uniq) <= 4) foreach (array_chunk($blocks, 8) as $row) {
echo " ";
foreach ($row as $b) echo chr(65 + array_search($b, $uniq));
echo "\n";
}
}
$raw = OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING;
show('AES-128-ECB', openssl_encrypt($pt, 'aes-128-ecb', $key, $raw));
show('AES-128-CBC', openssl_encrypt($pt, 'aes-128-cbc', $key, $raw, random_bytes(16)));
show('AES-128-GCM', openssl_encrypt($pt, 'aes-128-gcm', $key, OPENSSL_RAW_DATA, random_bytes(12), $tag));
AES-128-ECB: unique ciphertext blocks = 2 / 64
AABBBBAA
ABAAAABA
BABAABAB
BAAAAAAB
BABAABAB
BAABBAAB
ABAAAABA
AABBBBAA
AES-128-CBC: unique ciphertext blocks = 64 / 64
AES-128-GCM: unique ciphertext blocks = 64 / 64
La carita sale del texto cifrado con ECB sin necesidad de la clave: solo hay dos bloques distintos y su posición es el dibujo. Con datos reales pasa lo mismo. Un campo "estado" de 16 bytes, registros de largo fijo o IDs con relleno cifrados con ECB revelan qué filas son iguales y con qué frecuencia aparece cada valor.
ECB también aparece por accidente. En PHP, openssl_encrypt($data, 'aes-256-cbc', $key) sin IV muestra Warning: openssl_encrypt(): Using an empty Initialization Vector (iv) is potentially insecure and not recommended y luego usa un IV de puros ceros. Para un mensaje de un bloque obtuvimos exactamente el mismo texto cifrado que con ECB: sHQyEV+uqAy8hauXYqiQGw== en ambos casos.
Reutilizar el nonce: dos textos cifrados, un XOR
GCM es el modo CTR más un tag de autenticación. CTR convierte la clave y el nonce en un keystream y le aplica XOR al texto plano. Si usas dos veces la misma clave y el mismo nonce, el keystream es el mismo y se cancela:
c1 ⊕ c2 = (m1 ⊕ k) ⊕ (m2 ⊕ k) = m1 ⊕ m2
El atacante no necesita la clave, solo un texto plano que pueda adivinar. Aquí el primer mensaje es un aviso de mantenimiento que cualquiera puede leer en la página de estado.
<?php
// ~/demo/nonce.php — lo que se filtra al reutilizar un nonce de GCM
$m1 = 'Aviso: mantenimiento hoy a las 22:00'; // público, todos lo conocen
$m2 = 'Empleado 1234: evaluación A, aumento 5%'; // secreto
$key = random_bytes(32);
$nonce = random_bytes(12); // BUG: abajo se usa el mismo nonce dos veces
$c1 = openssl_encrypt($m1, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $t1);
$c2 = openssl_encrypt($m2, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $t2);
$n = min(strlen($c1), strlen($c2));
$xc = substr($c1, 0, $n) ^ substr($c2, 0, $n);
echo "c1 xor c2 == m1 xor m2 ? ", var_export($xc === (substr($m1, 0, $n) ^ substr($m2, 0, $n)), true), "\n";
echo "recovered m2 prefix: ", $xc ^ substr($m1, 0, $n), "\n"; // sin usar la clave
c1 xor c2 == m1 xor m2 ? true
recovered m2 prefix: Empleado 1234: evaluación A, aument
Recuperamos 36 de los 40 bytes del secreto: el aviso mide 36 bytes y ese es el único límite. Fíjate en que evaluación ocupa 11 bytes y no 10, porque el XOR trabaja sobre bytes UTF-8, no sobre caracteres (cómo el texto se convierte en bytes). Hay una consecuencia peor que describimos pero no ejecutamos: con dos mensajes bajo el mismo nonce, el atacante puede calcular la clave interna de autenticación de GCM y después falsificar tags válidos para los mensajes que quiera.
¿Cómo se reutiliza un nonce en código real? Un nonce constante "mientras tanto", un contador en memoria que vuelve a cero al reiniciar, dos servidores que cuentan desde 1, o un nonce sacado de la hora. Java protege uno de esos casos:
first: 19 bytes
java.security.InvalidAlgorithmParameterException: Cannot reuse iv for GCM encryption
new Cipher, same key+iv: accepted, 19 bytes
El mismo objeto Cipher se negó a cifrar otra vez con el mismo IV, pero un objeto Cipher nuevo lo aceptó. La biblioteca no puede protegerte entre objetos, procesos o servidores. La regla: genera un nonce aleatorio de 12 bytes para cada mensaje y guárdalo junto al texto cifrado. Con nonces aleatorios de 96 bits, mantén la cantidad de mensajes por clave muy por debajo de 2^32 (la guía del NIST para GCM). Si cifras más que eso, o no puedes garantizar buena aleatoriedad, usa XChaCha20-Poly1305 con su nonce de 24 bytes.
Cifrado sin autenticación: 100 se vuelve 900
El cifrado oculta los datos, pero no impide que alguien los cambie. En modo CTR, cambiar un bit del texto cifrado cambia el mismo bit del texto plano, y el atacante sabe dónde está amount=100 porque el formato es fijo.
<?php
// ~/demo/tamper.php — la misma edición de un byte contra CTR y contra GCM
$m = 'to=lucia&amount=100&cur=MXN';
$key = random_bytes(32);
$pos = strpos($m, 'amount=') + 7; // el '1' de 100
$flip = ord('1') ^ ord('9');
$iv = random_bytes(16);
$c = openssl_encrypt($m, 'aes-256-ctr', $key, OPENSSL_RAW_DATA, $iv);
$c[$pos] = chr(ord($c[$pos]) ^ $flip); // el atacante edita un byte, sin clave
echo "CTR decrypts to: ", openssl_decrypt($c, 'aes-256-ctr', $key, OPENSSL_RAW_DATA, $iv), "\n";
$n = random_bytes(12);
$c = openssl_encrypt($m, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, $tag);
$c[$pos] = chr(ord($c[$pos]) ^ $flip);
echo "GCM decrypt: "; var_dump(openssl_decrypt($c, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, $tag));
CTR decrypts to: to=lucia&amount=900&cur=MXN
GCM decrypt: bool(false)
CTR devolvió una transferencia de 900 que se ve perfectamente válida. GCM la rechazó porque el tag de 16 bytes ya no coincidía. Eso significa "cifrado autenticado" (AEAD): el descifrado devuelve el texto original o falla.
CBC tampoco es más seguro. Ciframos con la CLI de OpenSSL el archivo de 33 bytes memo: thanks!!!!to=alice;pay=100, cambiamos un byte del primer bloque cifrado y desciframos:
$ openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 -pass pass:demo -in t2.bin | xxd
00000000: 7fbf 389b 36ff 0355 7316 78ee 6aaf d17d ..8.6..Us.x.j..}
00000010: 746f 3d61 6c69 6365 3b70 6179 3d39 3030 to=alice;pay=900
00000020: 0a .
exit=0
El primer bloque quedó como basura, el segundo ahora dice pay=900 y el código de salida es 0. Un programa que solo mira el código de salida lo acepta. El padding tampoco ayuda: probamos 2.000 contraseñas incorrectas contra un archivo CBC y 8 "descifraron" sin el error bad decrypt (lo esperado es unas 2000/256 ≈ 7,8), produciendo basura con un padding que parecía válido.
El tag de 1 byte que PHP aceptó
El tag de GCM normalmente mide 16 bytes. Mientras probábamos la demo de manipulación, le pasamos tags más cortos al descifrado, y PHP 8.5.6 aceptó todas las longitudes hasta 1 byte:
tag len 16: ACCEPTED
tag len 12: ACCEPTED
tag len 8: ACCEPTED
tag len 4: ACCEPTED
tag len 1: ACCEPTED
Si tu código toma el tag de la petición o del valor guardado y lo pasa directo a openssl_decrypt, un atacante puede mandar un tag de 1 byte. Entonces falsificar un mensaje cuesta como mucho 256 intentos:
<?php
// ~/demo/shorttag.php — falsificar GCM contra un receptor que confía en la longitud del tag
$m = 'to=lucia&amount=100&cur=MXN';
$key = random_bytes(32); $n = random_bytes(12);
$c = openssl_encrypt($m, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, $tag);
$pos = strpos($m, 'amount=') + 7;
$c[$pos] = chr(ord($c[$pos]) ^ (ord('1') ^ ord('9'))); // manipulado; el tag real es desconocido
for ($b = 0; $b < 256; $b++) {
$r = openssl_decrypt($c, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, chr($b));
if ($r !== false) { echo "forged after ", $b + 1, " tries: $r\n"; break; }
}
function gcm_open(string $c, string $key, string $n, string $tag): string|false {
if (strlen($key) !== 32 || strlen($n) !== 12 || strlen($tag) !== 16) return false; // SOLUCIÓN
return openssl_decrypt($c, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $n, $tag);
}
forged after 225 tries: to=lucia&amount=900&cur=MXN
forged after 250 tries: to=lucia&amount=900&cur=MXN
forged after 152 tries: to=lucia&amount=900&cur=MXN
La clave y el nonce son aleatorios en cada ejecución, así que el número cambia (el promedio es unos 128). Con la comprobación de longitud, el mismo tag de 1 byte devuelve false. Un tag vacío fue rechazado incluso sin la comprobación. Java es más estricto: new GCMParameterSpec(32, iv) falló con Unsupported TLen value. Must be one of {128, 120, 112, 104, 96}. libsodium ni siquiera tiene un parámetro de longitud de tag.
El manejo de claves en PHP tiene el mismo silencio. openssl_encrypt con aes-256-cbc y la clave "k" dio el mismo texto cifrado que la clave "k" seguida de 31 bytes NUL, y una clave de 40 bytes dio el mismo resultado que sus primeros 32 bytes. Ninguna advertencia. O sea, openssl_encrypt($data, 'aes-256-cbc', 'password123', …) usa tu contraseña, rellenada con ceros, como clave AES cruda. No hay ninguna derivación de clave.
Un patrón completo: contraseña → clave → AES-GCM
Cuando la clave sale de una contraseña, el valor guardado lleva tres cosas además del texto cifrado: la sal aleatoria del KDF, el nonce aleatorio y un prefijo de versión para poder cambiar parámetros más adelante. La versión también va como datos asociados (AAD), que no se cifran pero sí se autentican.
# ~/demo/seal.py — PBKDF2 de la biblioteca estándar, AES-256-GCM del paquete "cryptography"
import base64, hashlib, os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag
def seal(password: str, plaintext: str, aad: bytes = b"v1") -> str:
salt, nonce = os.urandom(16), os.urandom(12)
key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 600_000, dklen=32)
ct = AESGCM(key).encrypt(nonce, plaintext.encode(), aad) # cifrado || tag de 16 bytes
return base64.b64encode(b"v1" + salt + nonce + ct).decode()
def open_(password: str, token: str, aad: bytes = b"v1") -> str:
raw = base64.b64decode(token)
if raw[:2] != b"v1" or len(raw) < 2 + 16 + 12 + 16:
raise ValueError("bad format")
salt, nonce, ct = raw[2:18], raw[18:30], raw[30:]
key = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 600_000, dklen=32)
return AESGCM(key).decrypt(nonce, ct, aad).decode()
tok = seal("correct horse", "Clave API de staging: demo-123")
print(len(tok), tok[:24] + "...")
print(open_("correct horse", tok))
try:
open_("wrong horse", tok)
except InvalidTag:
print("wrong password -> InvalidTag")
104 djE9s/lN3p4rqQIWKRgMkMDM...
Clave API de staging: demo-123
wrong password -> InvalidTag
Una contraseña incorrecta lanza InvalidTag siempre, no como los 8 falsos aciertos en 2.000 de CBC. El formato es portable. PHP abrió un valor sellado por Python solo con hash_pbkdf2 y openssl_decrypt, separando el tag del final:
<?php
$raw = base64_decode(trim(file_get_contents($argv[1])), true);
$salt = substr($raw, 2, 16); $nonce = substr($raw, 18, 12);
$tag = substr($raw, -16); $ct = substr($raw, 30, -16);
$key = hash_pbkdf2('sha256', 'correct horse', $salt, 600000, 32, true);
var_dump(openssl_decrypt($ct, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, 'v1'));
// string(12) "interop test"
¿Qué tan caro debe ser el KDF? Su trabajo es el mismo que en el hash de contraseñas (clase 2): que cada intento de adivinar cueste tiempo real. Medimos la mediana de 5 ejecuciones por configuración, con contraseña y sal fijas.
| KDF y configuración | Mediana |
|---|---|
| PBKDF2-SHA256, 10 000 iteraciones (Python) | 3,0 ms |
| PBKDF2-SHA256, 100 000 iteraciones (Python) | 30,5 ms |
| PBKDF2-SHA256, 600 000 iteraciones (Python) | 181,6 ms |
PBKDF2-SHA256, 600 000 iteraciones (PHP hash_pbkdf2) |
793,6 ms |
| scrypt, "interactive" de libsodium (16 MiB) | 33,9 ms |
scrypt N=2^17, r=8 (CLI de OpenSSL kdf, incluye arranque) |
0,30 s |
| Argon2id, "interactive" de libsodium (2 pasadas, 64 MiB) | 62,9 ms |
| Argon2id, "moderate" de libsodium (3 pasadas, 256 MiB) | 425,7 ms |
Python y PHP produjeron la misma clave PBKDF2 (96a5904c2e08c8da…), pero PHP tardó unas 4,4 veces más. Eso importa si eliges las iteraciones en un lenguaje y verificas en el otro. Argon2id y scrypt son mejores que PBKDF2 cuando los tienes, porque además cuestan memoria, y eso encarece mucho adivinar con GPU. PBKDF2 con 600 000 iteraciones es la cifra de OWASP para SHA-256 cuando es la única opción, como pasa con la biblioteca estándar de Python en esta Mac.
Si no necesitas contraseña (lo normal para datos del lado del servidor), sáltate el KDF: genera una clave de 32 bytes con random_bytes(32) o secrets.token_bytes(32) una sola vez, guárdala fuera de la base de datos (variable de entorno, gestor de secretos) y úsala directamente. Una clave aleatoria no necesita estiramiento.
openssl enc en la línea de comandos
La CLI de OpenSSL es donde mucha gente cifra respaldos, así que conviene saber qué hace realmente la 4.1.0.
$ openssl enc -aes-256-gcm -pass pass:demo -in msg.txt -out /dev/null
enc: AEAD ciphers not supported
enc: Use -help for summary.
exit=1
$ openssl enc -aes-256-cbc -pass pass:demo -in msg.txt -out c1.bin
*** WARNING : deprecated key derivation used.
Using -iter or -pbkdf2 would be better.
exit=0
encrechaza GCM. La CLI solo te da modos sin autenticación, y la manipulación de arriba aplica a todos los archivos que genera.- Sin
-pbkdf2ni-iter, el valor por defecto sigue siendo la derivación antigua de una sola pasada (EVP_BytesToKey). Solo avisa, y el código de salida es 0. Lo comprobamos en 4.1.0 porque no estábamos seguros de que siguiera así; sigue así. - Con
-pbkdf2,openssl enc -helpdice que las iteraciones por defecto son 10000. Eso tomó 3,0 ms en nuestra medición. Pasa-iter 600000o más. - Un archivo cifrado con
-pbkdf2y descifrado sin esa opción falla conbad decrypt. Las opciones no se guardan en el archivo; solo vanSalted__y la sal de 8 bytes. Anótalas junto al respaldo. -pass pass:…deja la contraseña en el historial de la shell y en la lista de procesos. Usa-pass file:o-pass env:.
Para respaldos cifrados es mejor una herramienta con cifrado autenticado (age y gpg son comunes; no las ejecutamos aquí). Si de verdad tienes que usar openssl enc, agrega un HMAC aparte sobre el archivo y verifícalo antes de descifrar.
Errores comunes y cómo corregirlos
| Error | Qué pasó en nuestras pruebas | Corrección |
|---|---|---|
| AES-ECB, o CBC sin IV | El ícono sobrevivió; 2 bloques distintos de 64 | Modo AEAD con nonce aleatorio |
| Nonce fijo o de contador reutilizado | 36 de 40 bytes secretos recuperados con un aviso público | Nonce aleatorio de 12 bytes por mensaje, o XChaCha20-Poly1305 |
| CTR/CBC sin MAC | amount=900 y pay=900 aceptados, salida 0 |
GCM o ChaCha20-Poly1305; revisa el resultado del descifrado |
| Longitud del tag tomada de la entrada | Tag de 1 byte aceptado, falsificado en ≤256 intentos | Exige tag de 16 bytes y nonce de 12 |
| Contraseña usada como clave | PHP rellenó "k" con bytes NUL en silencio |
KDF con sal, o una clave aleatoria de 32 bytes |
Valores por defecto de openssl enc |
KDF antigua, PBKDF2 con 10 000 iteraciones, sin integridad | -pbkdf2 -iter 600000 más un MAC, o una herramienta AEAD |
Ignorar un false del descifrado |
El código sigue con false o con una cadena vacía |
Trata el fallo como error; nunca "regreses" a los datos crudos |
Qué construcción elegir
| Situación | Usa | Por qué |
|---|---|---|
| PHP con libsodium | sodium_crypto_aead_xchacha20poly1305_ietf_* |
Tamaños fijos, nonce aleatorio de 24 bytes, sin elegir modos |
| Tiene que ser AES (política, interoperabilidad) | AES-256-GCM, nonce aleatorio de 12 bytes, tag de 16 bytes comprobado | AEAD estándar, acelerado por hardware |
| Muchísimos mensajes por clave, o RNG débil | XChaCha20-Poly1305 o AES-GCM-SIV | Nonces aleatorios seguros, o resistente al mal uso |
| Clave a partir de una contraseña humana | Argon2id → clave, luego AEAD | Cada intento cuesta tiempo y memoria |
| Clave secreta del servidor | random_bytes(32) guardada fuera de la BD |
No hace falta KDF |
| Archivo cifrado desde la CLI | Una herramienta AEAD, o enc más un HMAC aparte |
enc no hace AEAD |
Con libsodium, todo el lado de PHP son dos llamadas, y los datos asociados funcionan igual que en GCM:
<?php
$key = sodium_crypto_aead_xchacha20poly1305_ietf_keygen(); // 32 bytes aleatorios
$nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); // 24
$c = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt('to=lucia&amount=100&cur=MXN', 'order:42', $nonce, $key);
var_dump(sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($c, 'order:43', $nonce, $key)); // bool(false)
El mensaje de 27 bytes quedó en 43 (27 + tag de 16 bytes), y descifrar con order:43 en lugar de order:42 como AAD devolvió false.
Límites honestos y notas de versión
- Versiones: PHP 8.5.6 (OpenSSL 3.6.2, libsodium 1.0.22), CLI de OpenSSL 4.1.0, Python 3.9.6 (LibreSSL 2.8.3) con
cryptography48.0.0, Java 26. Lo del tag de 1 byte es lo que hizo PHP 8.5.6 aquí; no probamos otras versiones de PHP. Revisa la tuya con el bucle de arriba. - La recuperación de la clave de autenticación de GCM tras reutilizar un nonce está descrita, no ejecutada. La recuperación por XOR sí se ejecutó.
- Los tiempos del KDF son medianas de 5 ejecuciones en una sola M1 Pro; el número de scrypt por CLI incluye el arranque del proceso. Tu servidor va a dar otros números: mide allí y apunta a un costo que tu login pueda pagar.
sodium_crypto_aead_aes256gcm_is_available()devolviótrueen esta Mac. En algunas CPU devuelvefalse; XChaCha20-Poly1305 no tiene esa dependencia.- No ejecutamos CryptoKit de Swift ni
AesGcmde .NET en esta clase. - Nada de esto es un diseño completo de gestión de claves: la rotación, los respaldos de las claves y el control de acceso son trabajo aparte.
Ejercicios
1. Alguien de tu equipo guarda notas de clientes con openssl_encrypt($note, 'aes-256-ctr', $key, 0, $iv), donde $iv es una constante de 16 bytes del archivo de configuración. Nombra los dos problemas y qué puede hacer con cada uno un atacante que lee y escribe en la base de datos.
2. Recibes en PHP valores generados por el seal() de Python de arriba. Escribe, en orden, las comprobaciones que harías antes de llamar a openssl_decrypt.
Respuestas
1. (a) El IV es constante, así que todas las notas se cifran con el mismo keystream. Quien lea dos notas obtiene su XOR, y una nota conocida o adivinable (una plantilla como "El cliente llamó por…") revela las demás, como en la demo del nonce. (b) CTR no tiene integridad. Un atacante con permiso de escritura puede cambiar bytes en posiciones conocidas, como 100 → 900, y el descifrado funciona. Corrección: AES-256-GCM o XChaCha20-Poly1305 con un nonce aleatorio nuevo por nota guardado junto a ella, y tratar un false del descifrado como error.
2. Decodificar Base64 en modo estricto y fallar si da false; comprobar el prefijo v1; comprobar que el largo total sea al menos 2 + 16 + 12 + 16; separar sal (16), nonce (12), tag (últimos 16) y texto cifrado; confirmar que el tag mide exactamente 16 bytes y el nonce 12; derivar la clave con el mismo KDF y parámetros; pasar v1 como AAD; y usar el texto plano solo si el resultado no es false.
Lo que sigue
- Anterior: curso de seguridad básica 4/6: trampas al firmar JWT con HS256
- Siguiente: curso de seguridad básica 6/6: ataques de timing y comparación en tiempo constante
- Antes en la serie: hash, cifrado y codificación, hash de contraseñas con bcrypt y Argon2, números aleatorios seguros, tokens y secretos
- Cifrado en tránsito en lugar de en reposo: HTTPS, TLS y certificados (en inglés)
- La otra mitad de la seguridad web en PHP: XSS, CSRF y sesiones en PHP (en inglés)
- Todas las series de clases: la hoja de ruta para aprender a programar
Preguntas frecuentes
¿Puedo reutilizar un nonce con AES-GCM si la clave es secreta?
No. Con la misma clave y el mismo nonce, GCM genera el mismo keystream, así que el XOR de los dos textos cifrados es igual al XOR de los dos textos planos. En nuestra prueba bastó conocer un aviso público para leer casi todo el otro mensaje sin la clave. Además permite falsificar tags. Usa un nonce aleatorio de 12 bytes por mensaje, o XChaCha20-Poly1305 con nonce de 24 bytes.
¿Por qué openssl_decrypt aceptó un mensaje AES-GCM falsificado?
PHP 8.5.6 aceptó cualquier longitud de tag al descifrar, incluso 1 byte. Cambiamos amount=100 por amount=900 y probamos los 256 tags de 1 byte; uno pasó entre el intento 152 y el 250. Antes de llamar a openssl_decrypt, comprueba tú que el tag tenga exactamente 16 bytes y el nonce 12, o usa libsodium, que tiene tamaños fijos.
¿Es seguro openssl enc -aes-256-cbc con contraseña para cifrar archivos?
No con los valores por defecto. Sin -pbkdf2, OpenSSL 4.1.0 todavía usa la derivación de claves antigua y solo muestra una advertencia; con -pbkdf2 el valor por defecto es de apenas 10000 iteraciones. CBC no tiene integridad: cambiamos pay=100 por pay=900 en un archivo cifrado y el descifrado salió con 0. enc rechaza modos AEAD como GCM.