Cifrado en PHP: openssl_encrypt, libsodium y cómo proteger datos sensibles

Cifrar datos en PHP va más allá de usar md5() o base64_encode(): esas funciones no son cifrado. El cifrado real transforma los datos de modo que solo quien tiene la clave pueda recuperarlos. PHP ofrece dos caminos modernos para hacerlo: la extensión openssl con algoritmos estándar y libsodium, disponible desde PHP 7.2, que es más sencilla de usar correctamente.

openssl_encrypt con AES-256-GCM

AES-256-GCM es el modo recomendado para cifrado simétrico: proporciona confidencialidad y autenticidad del mensaje (AEAD), lo que protege tanto el contenido como su integridad:

<?php
function cifrar(string $texto, string $clave): string {
    $metodo = 'aes-256-gcm';
    $iv     = random_bytes(openssl_cipher_iv_length($metodo));  // 12 bytes para GCM
    $tag    = '';

    $cifrado = openssl_encrypt(
        $texto,
        $metodo,
        $clave,    // debe tener exactamente 32 bytes para AES-256
        OPENSSL_RAW_DATA,
        $iv,
        $tag,      // authentication tag (16 bytes)
        '',        // datos adicionales autenticados (AAD) — opcional
        16         // longitud del tag en bytes
    );

    if ($cifrado === false) {
        throw new RuntimeException('Error al cifrar: ' . openssl_error_string());
    }

    // Concatenar IV + tag + cifrado, y codificar en base64 para almacenamiento
    return base64_encode($iv . $tag . $cifrado);
}

function descifrar(string $datos_b64, string $clave): string {
    $metodo   = 'aes-256-gcm';
    $datos    = base64_decode($datos_b64, true);
    $iv_len   = openssl_cipher_iv_length($metodo);

    $iv      = substr($datos, 0, $iv_len);
    $tag     = substr($datos, $iv_len, 16);
    $cifrado = substr($datos, $iv_len + 16);

    $texto = openssl_decrypt(
        $cifrado,
        $metodo,
        $clave,
        OPENSSL_RAW_DATA,
        $iv,
        $tag
    );

    if ($texto === false) {
        throw new RuntimeException('Descifrado fallido: datos corruptos o clave incorrecta');
    }
    return $texto;
}

// Uso
$clave = random_bytes(32);  // 32 bytes para AES-256 — guarda esto de forma segura
$texto = 'Número de tarjeta: 4111-1111-1111-1111';

$cifrado  = cifrar($texto, $clave);
$original = descifrar($cifrado, $clave);

echo $original;  // Número de tarjeta: 4111-1111-1111-1111

Gestionar la clave de cifrado

La clave es lo más importante. Nunca la almacenes en el mismo lugar que los datos cifrados:

<?php
// Opción 1: variable de entorno (la más sencilla)
$clave = base64_decode($_ENV['ENCRYPTION_KEY']);
// En .env: ENCRYPTION_KEY=base64_de_32_bytes_aleatorios
// Generar: echo base64_encode(random_bytes(32));

// Opción 2: derivar desde una contraseña maestra con PBKDF2
function derivarClave(string $password, string $salt): string {
    return hash_pbkdf2('sha256', $password, $salt, 100000, 32, true);
}
$salt  = random_bytes(32);
$clave = derivarClave('password_maestra', $salt);
// Guarda el salt junto a los datos; la contraseña maestra en un gestor de secretos

// Opción 3: fichero de clave fuera del webroot
$clave = file_get_contents('/etc/miapp/encryption.key');

libsodium: cifrado más sencillo y seguro

libsodium está incluida en PHP desde 7.2 sin necesidad de instalar nada. Sus funciones son más difíciles de usar mal que las de openssl:

<?php
// sodium_crypto_secretbox: cifrado simétrico autenticado
$clave = sodium_crypto_secretbox_keygen();  // 32 bytes aleatorios
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);  // 24 bytes

$mensaje   = 'Datos confidenciales del paciente';
$cifrado   = sodium_crypto_secretbox($mensaje, $nonce, $clave);
$almacenar = base64_encode($nonce . $cifrado);

// Descifrar
$datos   = base64_decode($almacenar, true);
$nonce2  = substr($datos, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cifr2   = substr($datos, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);

$original = sodium_crypto_secretbox_open($cifr2, $nonce2, $clave);
if ($original === false) {
    throw new RuntimeException('Descifrado fallido');
}
echo $original;  // Datos confidenciales del paciente

// IMPORTANTE: limpiar la clave de la memoria
sodium_memzero($clave);

secretstream: cifrado de ficheros grandes

Para ficheros grandes, secretstream cifra por fragmentos sin cargar todo en memoria:

<?php
function cifrarFichero(string $entrada, string $salida, string $clave): void {
    $in  = fopen($entrada, 'rb');
    $out = fopen($salida,  'wb');

    [$estado, $cabecera] = sodium_crypto_secretstream_xchacha20poly1305_init_push($clave);
    fwrite($out, $cabecera);

    while (!feof($in)) {
        $fragmento = fread($in, 65536);  // 64 KB por fragmento
        $etiqueta  = feof($in)
            ? SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL
            : SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_MESSAGE;
        fwrite($out, sodium_crypto_secretstream_xchacha20poly1305_push($estado, $fragmento, '', $etiqueta));
    }
    fclose($in); fclose($out);
}

function descifrarFichero(string $entrada, string $salida, string $clave): void {
    $in  = fopen($entrada, 'rb');
    $out = fopen($salida,  'wb');

    $cabecera = fread($in, SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES);
    $estado   = sodium_crypto_secretstream_xchacha20poly1305_init_pull($cabecera, $clave);

    while (!feof($in)) {
        $fragmento_cifrado = fread($in, 65536 + SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES);
        [$fragmento] = sodium_crypto_secretstream_xchacha20poly1305_pull($estado, $fragmento_cifrado);
        fwrite($out, $fragmento);
    }
    fclose($in); fclose($out);
}

$clave = sodium_crypto_secretstream_xchacha20poly1305_keygen();
cifrarFichero('backup.sql', 'backup.sql.enc', $clave);
descifrarFichero('backup.sql.enc', 'backup_restaurado.sql', $clave);

El antipatrón AES-CBC sin autenticación

AES-CBC sin un MAC (Message Authentication Code) es vulnerable a ataques de padding oracle. Es el error histórico más común con openssl en PHP:

<?php
// MAL — AES-CBC sin autenticación del mensaje
function cifrarMal(string $texto, string $clave): string {
    $iv      = random_bytes(16);
    $cifrado = openssl_encrypt($texto, 'aes-256-cbc', $clave, OPENSSL_RAW_DATA, $iv);
    return base64_encode($iv . $cifrado);
    // Sin tag de autenticación ? vulnerable a ataques de manipulación
}

// BIEN — usa siempre GCM o CCM (modos AEAD) que autentican el mensaje
// O usa libsodium que hace esto por defecto

Cifrado asimétrico con openssl

Para compartir datos entre partes sin compartir la clave:

<?php
// Generar par de claves (normalmente se hace una vez y se guarda en ficheros)
$config = [
    'private_key_bits' => 2048,
    'private_key_type' => OPENSSL_KEYTYPE_RSA,
];
$recurso = openssl_pkey_new($config);
openssl_pkey_export($recurso, $clavePrivada);
$clavePublica = openssl_pkey_get_details($recurso)['key'];

// Cifrar con la clave pública (solo el dueño de la privada puede descifrar)
openssl_public_encrypt('Datos secretos', $cifrado, $clavePublica);
$almacenar = base64_encode($cifrado);

// Descifrar con la clave privada
openssl_private_decrypt(base64_decode($almacenar), $descifrado, $clavePrivada);
echo $descifrado;  // Datos secretos

Buenas prácticas de cifrado en PHP

  • Usa siempre modos AEAD: AES-256-GCM con openssl o secretbox/secretstream de libsodium. Nunca AES-CBC o AES-ECB sin MAC.
  • IV/nonce aleatorio por mensaje: nunca reutilices el mismo IV con la misma clave. Un IV repetido con AES-GCM rompe la seguridad completamente.
  • Limpia secretos de la memoria: usa sodium_memzero() para borrar claves y datos sensibles de variables PHP.
  • No inventar esquemas propios: usa las primitivas de libsodium o los modos AEAD de openssl tal como están diseñados. La criptografía propia tiene errores no obvios.
  • Las contraseñas se hashean, no se cifran: para contraseñas usa password_hash() / password_verify(), no cifrado simétrico.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP