Instalar librería Mcrypt de PHP

En el tema anterior decíamos que con el uso de sesiones en alojamientos compartidos alguién podría acceder a las variables de sesión en la carpeta /Temp. Podemos proteger esas variables cifrando sus datos. Puede que PHP no se haya instalado con un cifrador, por lo que tendremos que agregar ese complemento. En primer lugar recordamos como instalé PHP en mi servidor local Apache. Allí veíamos que no se instalaban todos lo complementos, por lo que ahora podemos hacerlo. Como estoy en Windows voy a agregar/quitar programas y busco PHP:

instalar mcrypt

Pulso "cambiar" y entro en la pantalla de instalación de PHP:

instalar mcrypt

Pulso "Change" para realizar cambios en la instalación:

instalar mcrypt

Dejo esta configuración tal como estaba (lo tenía instalado como módulo Apache 2.2) y pasamos a los complementos:

instalar mcrypt

Los que no están instalados aparecen con una aspa roja. Buscamos el complemento mcrypt y seleccionamos para instalarlo:

instalar mcrypt

Seguimos el resto de pasos hasta completar la instalación. Podemos ver que está instalado ejecutando la función phpinfo() y observar la parte del módulo Mcrypt con algo como esto:

instalar mcrypt
La función phpinfo() es un recurso de PHP que compone un documento HTML con todas las configuraciones del PHP instalado. Con la instalación suele incluirse un documento llamado info.php con sólo esta función:
<?php
    echo phpinfo();
?>
Es obvio que este documento no debe estar al alcance de los usuarios de una web, pues hemos de intentar que nadie conozca cuáles son nuestras configuraciones. Por ello nunca debe incluirse en una ruta con alcance público, aunque aquí estoy probando todo esto en mi servidor local por lo que no hay mayor riesgo.

Cifrando datos en las variables de sesión

Antes de continuar conviene aclarar que muchas veces se usa el término encriptar en lugar de cifrar (o desencriptar en lugar de descifrar). El término correcto es cifrar (y descifrar), que según la R.A.E. en su primera acepción dice "transcribir en guarismos, letras o símbolos, de acuerdo con una clave, un mensaje cuyo contenido se quiere ocultar. De hecho encriptar no aparece registrado en la R.A.E., aunque si aparece el término criptografía definido como el "arte de escribir con clave secreta o de un modo enigmático".

Para poner en práctica el cifrado de datos, haremos un ejemplo de un conjunto de 3 páginas similar a los ejemplos en temas anteriores. En la primera página (pagina1c.php) iniciaremos una sesión. Luego en la página 2 se ofrece al usuario un formulario para que nos envíe algún dato que cifraremos y almacenaremos en una variable de sesión. En la tercera página accederemos a ese dato descifrándolo y devolviéndolo al usuario. Puede ver el código completo de las tres páginas.

La pagina1c.php es igual que la de los ejemplos de los temas anteriores, sólo contiene un inicio de sesión, con dos enlaces a las páginas siguientes:

//Lo primero es iniciar la sesión antes de enviar nada, pero
//estableciendo el modo de usar sesiones sólo con cookies por si
//estuviera desactivado. También damos nombre a la sesión.
ini_set("session.use_cookies", 1);
ini_set("session.use_only_cookies", 1);
session_name("sesionC");
session_start();
//Ahora comprobamos que la sesión no haya sido iniciada
if (!isset($_SESSION["iniciada"])) {
    //Esto evita la fijación de sesión
    session_regenerate_id(true);
    //Ahora asignamos el parámetro de sesión por primera vez
    $_SESSION["iniciada"]  = "si";
}
//Ahora se construye la página
?><!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" 
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="es" xml:lang="es">
<head>
... 
        <li><a href="pagina2c.php">Ir a página 2c</a> para enviar una dirección de
        email y que sea cifrada y almacenada en una variable de sesión.</li>
        <li><a href="pagina3c.php">Ir a página 3c</a>, donde se recupera la dirección
        email descifrada.</li>
        ...   
    

En la pagina2c.php realizamos el cifrado y descifrado. Esquemáticamente hace lo siguiente:

<?php
...
session_name("sesionC");
session_start();
...   
//Comprobamos si la sesión ya fue iniciada
if (!isset($_SESSION["iniciada"])){//LA SESIÓN NO HA SIDO INICIADA
    //Si se ha llegado aquí es que es una sesión inesperada.
    ...DESTRUIMOS SESIÓN Y REDIRIGIMOS A pagina1c.php...
} else {//LA SESIÓN YA FUE INICIADA
    //Vemos si existe la variable de sesión almacenada
    if (isset($_SESSION["email"])) {
        ...DESCIFRAMOS EL EMAIL...               
    }
    //Si está página se está recibiendo por la ejecución del botón
    //submit del formulario, tomamos el valor del email recibido
    if (isset($_POST["email"])){
        ...CIFRAMOS EL EMAIL... 
    }    
}
?>
...
<html ...>
    ...
    <form action="pagina2c.php" method="post">
        <label>Email:
        <input type="text" name="email" size="50" maxlength="50"
        style="font-family: Courier New; color: maroon; "
        value ="<?php echo $un_email; ?>" />
        </label>
        <input type="submit" value="enviar" />
    </form>
    ...   
    

En esta pagina2c.php primero hacemos session.start para iniciar la sesión denominada "sesionC". Luego comprobamos si no hay sesión iniciada anteriormente con la variable $_SESSION["iniciada"], con lo cual sería una sesión inesperada. Esta parte es igual que lo que explicamos en el tema 2 sobre la destrucción de sesiones inesperadas.

En otro caso la sesión ya estaba iniciada. Recordemos que el ejemplo almacenará la variable de sesión $_SESSION["email"] en la carpeta /Temp de sesiones, y que nuestro propósito es cifrar esa variable para que no pueda ser leída. Por lo tanto en primer lugar debemos comprobar si existe la variable y en ese caso proceder a descifrarla e incluirla en el formulario que se acompaña en esta página. Pero si no existe la variable, comprobaremos si la hemos recibido por POST a través de ese formulario, en cuyo caso la ciframos y guardamos en la sesión. La pagina3c.php sólo realiza la parte del descifrado.

Si en pagina2c.php envíamos algo como "esto está cifrado" por el formulario, en la variable de sesión $_SESSION["email"] quedará almacenada esa frase cifrada como pHuY/TE2xvbrPWNEIKGI6S+hSGqP8wg6. Si vamos a la carpeta /Temp y buscamos el archivo de sesión (cuyo identificador vemos expuesto en la página), abriéndolo en modo texto aparecerá esto:

iniciada|s:2:"si";email|s:32:"pHuY/TE2xvbrPWNEIKGI6S+hSGqP8wg6";

En principio parece que hemos conseguido nuestro propósito: cualquiera que rebusque en /Temp sólo encontrará datos cifrados. Pero aún podemos mejorarlo. En la página con el código completo puede ver con comentarios detallados como funciona el cifrado y descifrado, por lo que no voy a extenderme en exceso aunque exponemos ahora el código de la parte de cifrado para comentar algunos detalles, con enlaces al manual PHP para más información:

...
...Nota: en la variable $un_email tenemos un dato que vamos a cifrar...
...
$cifrador = mcrypt_module_open(MCRYPT_DES, "", MCRYPT_MODE_ECB, "");
$maximo_tamanyo_vector_inicio = mcrypt_enc_get_iv_size($cifrador);
//ECB ignora el vector inicio, pero hay que ponerlo
$vector_inicio = mcrypt_create_iv($maximo_tamanyo_vector_inicio, MCRYPT_RAND );
$maximo_tamanyo_llave = mcrypt_enc_get_key_size($cifrador);
$llave_md5 = md5("1234");
$llave = substr($llave_md5, 0, $maximo_tamanyo_llave);
mcrypt_generic_init($cifrador, $llave, $vector_inicio);
$un_email_cifrado = mcrypt_generic($cifrador, $un_email);
mcrypt_generic_deinit($cifrador);
mcrypt_module_close($cifrador);
$un_email_cifrado_64 = base64_encode($un_email_cifrado);
$_SESSION["email"] = $un_email_cifrado_64; 
    

En primer lugar el algoritmo usado para el cifrado es el DES en modo ECB, declarado con las constantes MCRYPT_DES y MCRYPT_MODE_ECB respectivamente al abrir el módulo cifrador. Este modo ECB cifra bloques de 64 bits de forma independiente. Por ejemplo, la cadena 12345678 tiene 64 bits, cuya cadena cifrada y sin aún codificarla en base64 sería CDCF8812BD2ECCB8 (presentada en hexadecimal). Si duplicamos la cadena inicial 1234567812345678 también se cifra igual el siguiente bloque CDCF8812BD2ECCB8CDCF8812BD2ECCB8. Si hay bloques cifrados repetidos, parece que se podrían usar técnicas que logran descifrar el mensaje. Además este modo ECB cifra el mensaje siempre de la misma forma. Para evitar ambas cosas deben usarse otros modos como CBC cuyo detalle veremos en el próximo apartado.

Pero el problema más importante es la sentencia $llave_md5 = md5("1234") donde especificamos la contraseña de cifrado, que en este ejemplo hemos puesto "1234". Si estamos intentando impedir que alguién interno a nuestro alojamiento pueda ver nuestras sesiones en /Temp, nada le impedirá ver el código fuente de este algoritmo y coger esta contraseña. Como el cifrador en modo ECB cifra siempre igual para una misma contraseña, no tendría ningún problema para descifrar el dato usando su propio código.

Detalles del descifrado

Antes de ver el siguiente apartado donde intentaremos resolver los problemas planteados, conviene detenerse en el descifrado:

...
//Vemos si existe la variable de sesión almacenada
if (isset($_SESSION["email"])) {
    //Extraemos el email cifrado y codificado en base64
    $un_email_cifrado_64 = $_SESSION["email"];
    if ($un_email_cifrado_64 != ""){
        //decodificamos base64 para dejarlo en binario
        $un_email_cifrado = base64_decode($un_email_cifrado_64);
        //Abrimos cifrador, IV, llave e inicializamos cifrador de la
        //misma forma que en la parte de cifrado (ver allí comentarios)
        $cifrador = mcrypt_module_open(MCRYPT_DES, "", MCRYPT_MODE_ECB, "");
        $maximo_tamanyo_vector_inicio = mcrypt_enc_get_iv_size($cifrador);
        $vector_inicio = mcrypt_create_iv($maximo_tamanyo_vector_inicio, MCRYPT_RAND );
        $maximo_tamanyo_llave = mcrypt_enc_get_key_size($cifrador);
        $llave_md5 = md5("1234");
        $llave = substr($llave_md5, 0, $maximo_tamanyo_llave);
        mcrypt_generic_init($cifrador, $llave, $vector_inicio);
        //Desciframos el email
        $un_email = mdecrypt_generic($cifrador, $un_email_cifrado);
        //Quitamos los nulos de relleno de la derecha
        $un_email = rtrim($un_email, "\0");
        //Finalizamos cifrador y lo cerramos
        mcrypt_generic_deinit($cifrador);
        mcrypt_module_close($cifrador);
    }        
}    
...

Si hay una variable almacenada $_SESSION["email"] la decodificamos base64 para luego descifrarla. El proceso es parecido al cifrado. Se abre un cifrador, un vector inicio, se ajusta el tamaño de la llave y se inicia el cifrador. Ahora desciframos con mdecrypt_generic y ya tenemos nuestro dato descifrado.

Hay un detalle a tener en cuenta cuando el cifrador se ve obligado a rellenar por la derecha con caracteres nulos. La cadena 1234567890 tiene 80 bits (10 bytes). Como el cifrador DES trabaja en bloques de 64 bits (8 bytes), antes de cifrar esa cadena le agrega 6 bytes a la derecha con caracteres nulos para conseguir 2 bloques completos de 64 bits. Si ciframos y desciframos esa cadena 1234567890 obtenemos en el navegador Firefox 3.6 lo siguiente:

fffd

La referencia de caracter &xFFFD; en UNICODE se usa para representar caracteres irreconocibles cuando estamos usando, por ejemplo, la codificación UTF-8 en este documento. El caracter es representable gráficamente si lo incluimos en un elemento HTML como <code>&xFFFD;</code> y también dentro de un <input>. Si estamos usando Firefox 3.6 podemos verlo en pantalla: . En otros navegadores no aparecerá el caracter o bien se verá un cuadrado indicando que no reconoce el caracter UNICODE.

Para evitar esto hemos de quitar el relleno del cifrador cuando sea necesario, para lo cual usamos la función rtrim($un_email, "\0"). Así eliminamos por la derecha todos los caracteres nulos.

Cifrado con llave compartida

Los problemas señalados al final del apartado 2 eran:

  • El modo de cifrado ECB no es seguro pues cifra siempre igual. Hay que usar otro modo donde el vector IV modifique el cifrado en cada vez y en cada bloque.
  • Usar la contraseña o llave de cifrado en el código del script es una vulnerabilidad pues puede ser leída.

Para evitar esto componemos otro grupo de tres páginas siguiendo el mismo ejemplo. Puede consultar el código completo de estas páginas. Con este enlace pagina1d.php podemos ver en ejecución la página inicial que ahora tiene algunas cosas nuevas:

<?php
ini_set("session.use_cookies", 1);
ini_set("session.use_only_cookies", 1);
session_name("sesionD");
session_start();
//Comprobamos si la sesión ya fue iniciada
$hay_sesion = isset($_SESSION["iniciada"]);
//Vemos si se está recibiendo un POST
$hay_post = (!empty($_POST));
//Vemos si se ha pulsado el botón de finalizar sesión
$finaliza_sesion = isset($_POST["fin-sesion"]);  
...
if (!$hay_sesion) {//CON SESIONES NO INICIADAS
    session_regenerate_id(true);
    $llave_base = md5(uniqid(rand(), true));
    $longitud = strlen($llave_base);
    $mitad = $longitud / 2;
    $llave_servidor = substr($llave_base, 0, $mitad);
    $_SESSION["iniciada"]  = $llave_servidor;
    $llave_cliente = substr($llave_base, $mitad, $longitud);    
    setcookie("llave-sesion", $llave_cliente, 0,  dirname($_SERVER["PHP_SELF"]));
} else {//CON SESIONES INICIADAS
    if ($hay_post && $finaliza_sesion){
        $_SESSION = array();
        setcookie(session_name(), "", time()-42000, dirname($_SERVER["PHP_SELF"]));
        setcookie("llave-sesion", "", time()-42000, dirname($_SERVER["PHP_SELF"]));  
        session_destroy();
        $host  = $_SERVER["HTTP_HOST"];
        $uri   = rtrim(dirname($_SERVER["PHP_SELF"]), "/\\");
        $pagina = "pagina1d.php";
        header("Location: http://$host$uri/$pagina");
        exit;    
    }
}
//Ahora se construye la página
?>
    ...
    <form action="<?php echo $_SERVER['PHP_SELF']; ?>" method="post">
        <input name="fin-sesion" type="submit" value="finalizar sesión" />
    </form>
    ...

Conviene realizar una recogida de identificaciones iniciales para ver si la sesión ha sido iniciada o hay algo en el POST. Así declaramos booleanos $hay_sesion, $hay_post y $finaliza_sesion, valores que nos permitirán clarificar su manejo en el código siguiente.

En este grupo de páginas con sesión acompañaremos siempre un fomulario con sólo el botón de envío para finalizar la sesión. Aunque en este ejemplo remitimos a la página de inicio pagina1d.php con fines didácticos, en una situación práctica deberíamos finalizar la sesión y salir del grupo de páginas protegidas, enviando al usuario a una página sin sesión.

Si no hay sesión previa iniciada hacemos lo mismo que otras veces regenerando el identificador. Pero ahora agregamos el código necesario para construir la llave o contraseña para el cifrado de datos. En primer lugar construimos una $llave_base usando la función PHP uniqid() que genera una cadena identificadora única basada en el reloj. El primer argumento es un número aleatorio generado por la función PHP rand(), evitando posible generación del mismo identificador por varios sitios que comparten el mismo reloj, como es el caso del alojamiento compartido. El segundo argumento es true que agrega entropía adicional lo que hará que los resultados sean más únicos.

En definitiva se trata de generar una cadena de 23 caracteres que nos servirá de contraseña o llave para cifrar nuestros datos. Luego obtenemos la firma (denominado también valor resumen, valor hash, signatura, etc.) de la llave base con el algoritmo MD5, con lo cual tenemos una cadena de 32 caracteres (256 bits). Esta aún no es la llave final, pues ahora la dividiremos en dos partes. La primera mitad ($llave_servidor) la guardaremos en la variable de sesión $_SESSION["iniciada"] y la otra mitad ($llave_cliente) la enviaremos con una cookie al usuario con setcookie("llave-sesion", ...). Cuando vayamos a cifrar o descifrar algo en la página 2 y siguientes hemos de reunir las dos partes concatenando ambas mitades y aplicando la firma de nuevo md5($_SESSION["iniciada"].$_COOKIE["llave-sesion"]). Usar la firma otra vez es importante pues esa concatenación tiene 256 bits y la contraseña que necesita el algoritmo DES es de sólo 64 bits (56 útiles). Como se toman los primeros caracteres hemos de pasar otra vez la firma pues con sólo la media llave del servidor nos serviría para descifrar el mensaje.

Se observa que si se recibe el botón de finalizar sesión, lo cual se detecta con $hay_post && $finaliza_sesion, entonces destruimos la sesión como en otros ejemplos. Además también anulamos la cookie del navegador donde está almacenada la media llave del cliente.

En la pagina2.php hacemos el cifrado de un dato recibido desde un formulario. En el código de esa página puede ver todos los detalles, exponiéndo a continuación sóo lo que nos interesa comentar sobre cifrado:

...
if (!$hay_sesion || ($hay_sesion && $hay_post && $finaliza_sesion)){
    //LA SESIÓN NO HA SIDO INICIADA O FINALIZAMOS SESIÓN
    ... aquí se destruye sesión ...
} else  {
    //LA SESIÓN YA FUE INICIADA
    if (isset($_COOKIE["llave-sesion"])){
        $llave_doble = md5($_SESSION["iniciada"].$_COOKIE["llave-sesion"]);
        //Vemos si existen las variables de sesión almacenadas
        if (isset($_SESSION["email"]) && ($_SESSION["email"] != "")
            && isset($_SESSION["iv"])) {
            //Extraemos el email cifrado y codificado en base64
            ... aquí se extrae y descifra el email que está en $_SESSION["email"] ...
        }
        //Recogemos el email recibido
        if (isset($_POST["email"])){
            $un_email = htmlspecialchars($_POST["email"], ENT_QUOTES);
            ...
            //CIFRAMOS
            $cifrador = mcrypt_module_open(MCRYPT_DES, "", MCRYPT_MODE_CBC, "");
            $maximo_tamanyo_vector_inicio = mcrypt_enc_get_iv_size($cifrador);
            $vector_inicio = mcrypt_create_iv($maximo_tamanyo_vector_inicio, MCRYPT_RAND);
            $_SESSION["iv"] = base64_encode($vector_inicio);;
            $maximo_tamanyo_llave = mcrypt_enc_get_key_size($cifrador);
            $llave = substr($llave_doble, 0, $maximo_tamanyo_llave);
            mcrypt_generic_init($cifrador, $llave, $vector_inicio);
            $un_email_cifrado = mcrypt_generic($cifrador, $un_email);
            mcrypt_generic_deinit($cifrador);
            mcrypt_module_close($cifrador);
            $_SESSION["email"] = base64_encode($un_email_cifrado);
            ...
        }        
    }
}
    

En el código del ejemplo del apartado 2 se explica con detalle en los comentarios cada una de las sentencias involucradas en un cifrado. El descifrado es similar. De todas formas volvemos a repetir los pasos del cifrado para este ejemplo:

  1. Abrimos un cifrador con mcrypt_module_open, usando el algoritmo DES y el modo CBC por medio de las constantes señaladas.
  2. El modo CBC requiere un vector de inicialización (IV: initialization vector), pero primero obtenemos el tamaño máximo de ese vector para el algoritmo que estamos usando, lo cual conseguimos con mcrypt_enc_get_iv_size($cifrador).
  3. Creamos un IV con mcrypt_create_iv aplicándole ese tamaño máximo y la constante MCRYPT_RAND si estamos en PHP bajo Windows (en otro caso consulte el manual PHP).
  4. Ese IV lo codificamos en base64 para almacenarlo en la variable de sesión $_SESSION["iv"].
  5. Obtenemos el máximo tamaño de la llave para ese algoritmo, lo que se consigue con mcrypt_enc_get_key_size($cifrador).
  6. Nuestra llave doble es mayor que el tamaño que necesitamos, por lo que la recortamos con substr.
  7. Iniciamos el cifrador con mcrypt_generic_init aplicándole la llave y el vector de inicialización.
  8. Ciframos nuestro dato con mcrypt_generic.
  9. Finalizamos el cifrador con mcrypt_generic_deinit.
  10. Cerramos el módulo cifrador con mcrypt_module_close.
  11. Guardamos el dato cifrado en $_SESSION["email"] después de haberlo codificado en base64.

En resalte azul hemos señalado algunas cosas. Primero vemos como se recompone la $llave_doble si encontramos la cookie con la media llave que se envió al cliente. Esta será la contraseña para descifrar $_SESSION["email"] o cifrar $un_email que se haya recibido por POST. El algoritmo DES se abre en modo CBC. Así evitamos bloques cifrados iguales. Este modo requiere que se guarde el vector de inicialización (IV) para luego ser usado en el descifrado. Podemos almacenarlo en una variable $_SESSION["iv"]. Lo codificamos en base64 para no incluir caracteres de control en el archivo de sesión. En la ejecución de pagina2d.php se observa que cada vez que se envía el dato, éste es cifrado de forma distinta. Sucede esto porque se genera un IV diferente en cada apertura del cifrador dando lugar a un cifrado distinto. Aunque es una mejora de seguridad, obliga a tener que almacenar ese IV en algún sitio para usarlo en el descifrado.

Si enviamos la cadena "esto está cifrado" en esa pagina2d.php, obtenemos esta captura de pantalla:

muestra

En ese momento esa cadena cifrada es KddCTD+Yh24puQchmhskre6HO+tjtgF0, pero si la envíamos de nuevo obtendremos otro ciframiento. En la carpeta de sesiones /Temp encontramos el archivo de sesión con este contenido:

iniciada|s:16:"b2ab7f14b040b836";
iv|s:12:"aQT4gWAYOCU=";
email|s:32:"KddCTD+Yh24puQchmhskre6HO+tjtgF0";
    

Si alguién mira este archivo sólo podrá acceder a la media llave del servidor y al IV. Sin la otra media llave del cliente no podría descifrar el email. Ya no tenemos el problema planteado de que alguién pudiera tomar la contraseña escrita en el código de nuestro script. Sin embargo se ha de tener en cuenta que se envía al cliente la media llave a través de una cookie. En las peticiones del cliente siempre acompañará esta cookie, por lo que este mecanismo no se libra de que alguién pueda interceptarla y obtener la media llave. Además si se recibe el dato por POST para luego ser cifrado, también puede ser interceptado antes de llegar al servidor. E incluso en la pagina3d.php volvemos a enviar el email descifrado al cliente.

En definitiva, este mecanismo sólo serviría para proteger los datos de las variables de sesión desde la perspectiva de que otros sitios del alojamiento compartido pudieran verlas. Y si además la sesión estuviera finalizada porque el usuario cerró el navegador, aunque aún existiera la sesión en /Temp, ya no habría forma de recuperar la otra media llave del cliente para descifrar esas variables, pues la cookie ya no existiría en el navegador del cliente. Es desde este punto de vista en que esta estrategia podría ser interesante, puesto que si estamos buscando cifrar todo el flujo de datos tendremos que ir a técnicas más complejas como el protocolo HTTPS.