Recientemente asistí a Laracon EU 2018, donde Marcus Bointon dio una gran charla sobre Cripto en PHP 7.2. Dejé la charla con una apreciación mucho mayor de lo complicada que es la criptografía, pero también de cómo PHP está haciendo que el cifrado sea más accesible gracias a la introducción del Sodio. El cifrado de datos en PHP es algo en lo que he estado trabajando como parte de mi trabajo en SpinupWP, así que pensé que era hora de compartir algunas ideas. ¡Abróchate el cinturón, porque esto podría ser un viaje lleno de baches!
Tipos de cifrado
Hay una gama de diferentes métodos de cifrado en uso hoy en día, los más comunes son el hash, el cifrado de clave secreta y el cifrado de clave pública. Además, cada método de cifrado tiene múltiples algoritmos o cifrados para elegir (cada uno con sus propias fortalezas y debilidades). En este artículo nos centraremos en la implementación de hashing y cifrado de clave secreta.
Hashing
Un algoritmo de hashing toma un valor de entrada y lo transforma en un resumen de mensaje. En pocas palabras, los valores de texto plano se transforman en un hash de longitud fija y solo se pueden validar pasando el valor original al algoritmo de hash. Esto hace que el hash sea perfecto para almacenar contraseñas de usuario.
Vale la pena señalar que el hash no es una solución a prueba de balas y no todos los algoritmos de hash son iguales. Considere MD5 y SHA1, que son rápidos y eficientes, lo que los hace ideales para la suma de comprobación y la verificación de archivos. Sin embargo, su velocidad los hace inadecuados para hashear la contraseña de un usuario. Con el poder computacional actual de las GPU modernas, una contraseña se puede descifrar por fuerza bruta en cuestión de minutos, revelando la contraseña de texto plano original. En su lugar, se deben usar algoritmos de hash intencionalmente más lentos, como bcrypt o Argon2.
Si bien una contraseña con hash generada por cualquier algoritmo ciertamente oscurecerá los datos originales y ralentizará a cualquier posible atacante, nosotros, como desarrolladores, debemos esforzarnos por usar el algoritmo más fuerte disponible. Afortunadamente, PHP hace esto fácil gracias a password_hash()
.
$hash = password_hash($password, PASSWORD_DEFAULT);
La función password_hash()
no solo utiliza un algoritmo de hash unidireccional seguro, sino que también maneja automáticamente salt y evita ataques de canal lateral basados en el tiempo. A partir de PHP 5.5, bcrypt se utilizará para generar el hash, pero esto cambiará en el futuro a medida que se agreguen algoritmos de hash más nuevos y seguros a PHP. Argon2 es probable que se convierta en el próximo algoritmo de hash predeterminado y se puede usar hoy (en PHP 7.2) pasando la bandera PASSWORD_ARGON2I
en lugar de PASSWORD_DEFAULT
.
Verificar la contraseña de un usuario también es un proceso trivial gracias a la función password_verify()
. Simplemente pase la contraseña de texto plano proporcionada por el usuario y compárela con el hash almacenado, de la siguiente manera:
if (password_verify($password, $hash)) { echo "Let me in, I'm genuine!";}
Observe cómo se realiza la verificación de contraseñas en PHP. Si está almacenando las credenciales de un usuario en una base de datos, es posible que se incline a hacer hash con la contraseña introducida al iniciar sesión y luego realizar una consulta de base de datos, como se indica a continuación:
SELECT * FROM usersWHERE username = 'Ashley'AND password = 'password_hash'LIMIT 1;
Este enfoque es susceptible a ataques de canal lateral y debe evitarse. En su lugar, devuelva el usuario y luego verifique el hash de contraseña en PHP.
SELECT username, password FROM usersWHERE username = 'Ashley'LIMIT 1;
Si bien el hash es ideal para almacenar la contraseña de un usuario, no funciona para datos arbitrarios a los que nuestra aplicación necesita acceder sin la intervención del usuario. Consideremos una aplicación de facturación, que encripta la información de la tarjeta de crédito de un usuario. Cada mes, nuestra aplicación debe facturar al usuario por el uso del mes anterior. El Hash de los datos de la tarjeta de crédito no funcionará porque requiere que nuestra aplicación conozca los datos originales para recuperarlos en texto plano.
¡Cifrado de clave secreta al rescate!
Cifrado de clave secreta
El cifrado de clave secreta (o cifrado simétrico, como también se le conoce) utiliza una sola clave para cifrar y descifrar datos. Veamos cómo implementaríamos tal mecanismo usando Sodio, que fue introducido en PHP 7.2. Si está ejecutando una versión anterior de PHP, puede instalar sodium a través de PECL.
Primero necesitamos una clave de cifrado, que se puede generar utilizando la función random_bytes()
. Por lo general, lo hará solo una vez y lo almacenará como una variable de entorno. Recuerde que esta llave debe mantenerse en secreto a toda costa. Una vez que la clave está comprometida, también lo está cualquier dato cifrado.
$key = random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
Para cifrar el valor lo pasamos a sodium_crypto_secretbox()
con nuestro $key
y un $nonce
. El nonce se genera usando random_bytes()
, porque el mismo nonce nunca debe reutilizarse.
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);$ciphertext = sodium_crypto_secretbox('This is a secret!', $nonce, $key);
Esto presenta un problema porque necesitamos el nonce para descifrar el valor más adelante. Afortunadamente, los nonce no tienen que mantenerse en secreto, por lo que podemos anteponerlos a nuestro $ciphertext
y luego base64_encode()
el valor antes de guardarlos en la base de datos.
$encoded = base64_encode($nonce . $ciphertext);var_dump($encoded);// string 'v6KhzRACVfUCyJKCGQF4VNoPXYfeFY+/pyRZcixz4x/0jLJOo+RbeGBTiZudMLEO7aRvg44HRecC' (length=76)
Cuando se trata de descifrar el valor, hacemos lo contrario.
$decoded = base64_decode($encoded);
Debido a que conocemos la longitud de nonce, podemos extraerlo usando mb_substr()
antes de descifrar el valor.
$nonce = mb_substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit');$ciphertext = mb_substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, '8bit');$plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $key);var_dump($plaintext);// string 'This is a secret!' (length=17)
¡Eso es todo lo que hay para el cifrado de claves secretas en PHP, gracias al Sodio!
Cifrado de envolvente
Si bien el enfoque descrito anteriormente es sin duda un paso en la dirección correcta, aún deja a nuestros datos vulnerables si la clave secreta se ve comprometida. Consideremos a un usuario malicioso que obtiene acceso al servidor que aloja nuestra aplicación. En este escenario, es probable que el atacante pueda descubrir nuestra clave secreta que usamos para cifrar los datos. Esto deja nuestros datos completamente expuestos.
La solución simple es no almacenar nuestra clave secreta en la misma ubicación que los datos cifrados, pero esto presenta un problema. ¿Cómo ciframos y desciframos bajo demanda? Ingresa al Servicio de Administración de Claves en la Nube de Google (KMS en la Nube).
Cloud KMS es un servicio proporcionado por Google para alojar claves criptográficas de forma segura. Proporciona una variedad de funciones útiles en torno al almacenamiento de claves, incluida la rotación automática de claves y la destrucción retardada de claves. Sin embargo, en este ejemplo, nos preocupa principalmente almacenar nuestra clave secreta lejos de nuestros datos.
Para hacer las cosas más seguras, vamos a usar una técnica conocida como cifrado de envolvente. Esencialmente, el cifrado de envolvente implica cifrar claves con otra clave. Hacemos esto por dos razones:
- Cloud KMS tiene un límite de tamaño de 64 kb en los datos que se pueden cifrar y descifrar. Por lo tanto, es posible que no sea posible enviar todos los datos de un solo golpe.
- Lo que es más importante, no queremos enviar nuestros datos confidenciales de texto plano a un tercero, independientemente de lo confiables que puedan parecer.
En lugar de enviar nuestros datos de texto plano a Cloud KMS, generaremos una clave de cifrado única cada vez que escribamos datos confidenciales en la base de datos. Esta clave se conoce como clave de cifrado de datos (DEK), que se utilizará para cifrar nuestros datos. Luego, el DEK se envía a Cloud KMS para ser cifrado, lo que devuelve una clave de cifrado (conocida como KEK). Finalmente, el KEK se almacena lado a lado en la base de datos junto a los datos cifrados y el DEK se destruye. El proceso se ve así:
- Generar una clave de cifrado única (DEK)
- Cifrar los datos mediante cifrado de clave secreta
- Enviar la clave de cifrado única (DEK) a Cloud KMS para su cifrado, que devuelve la KEK
- Almacenar los datos cifrados y la clave cifrada (KEK) lado a lado
- Destruir la clave generada (DEK)
Al descifrar datos, el proceso se invierte:
- Recuperar los datos cifrados y la clave cifrada (KEK) de la base de datos
- Enviar el KEK a la nube KMS para descifrarlo, lo que devuelve el DEK
- Usar el DEK para descifrar nuestros datos cifrados
- Destruir el DEK
Con esto en mente he creado una clase auxiliar para realizar cifrado de sobres. No voy a repasar los pasos requeridos en la consola de Google Cloud, ya que las guías de inicio rápido y Autenticación describen todo lo que necesita para comenzar. En aras de la brevedad no hay manejo de errores, etc. en este ejemplo.
<?phpuse Google_Service_CloudKMS as Kms;use Google_Service_CloudKMS_DecryptRequest as DecryptRequest;use Google_Service_CloudKMS_EncryptRequest as EncryptRequest;class KeyManager{ private $kms; private $encryptRequest; private $decryptRequest; private $projectId; private $locationId; private $keyRingId; private $cryptoKeyId; public function __construct(Kms $kms, EncryptRequest $encryptRequest, DecryptRequest $decryptRequest, $projectId, $locationId, $keyRingId, $cryptoKeyId) { $this->kms = $kms; $this->encryptRequest = $encryptRequest; $this->decryptRequest = $decryptRequest; $this->projectId = $projectId; $this->locationId = $locationId; $this->keyRingId = $keyRingId; $this->cryptoKeyId = $cryptoKeyId; } public function encrypt($data) { $key = random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $ciphertext = sodium_crypto_secretbox($data, $nonce, $key); return ; } public function decrypt($secret, $data) { $decoded = base64_decode($data); $key = $this->decryptSecret($secret); $nonce = mb_substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit'); $ciphertext = mb_substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, '8bit'); return sodium_crypto_secretbox_open($ciphertext, $nonce, $key); } private function encryptKey($key) { $this->encryptRequest->setPlaintext(base64_encode($key)); $response = $this->kms->projects_locations_keyRings_cryptoKeys->encrypt( $this->getResourceName(), $this->encryptRequest ); return $response; } private function decryptSecret($secret) { $this->decryptRequest->setCiphertext($secret); $response = $this->kms->projects_locations_keyRings_cryptoKeys->decrypt( $this->getResourceName(), $this->decryptRequest ); return base64_decode($response); } private function getResourceName() { return sprintf( 'projects/%s/locations/%s/keyRings/%s/cryptoKeys/%s', $this->projectId, $this->locationId, $this->keyRingId, $this->cryptoKeyId ); }}
Notará que los métodos de cifrado y descifrado reales son casi idénticos a la implementación de clave secreta introducida anteriormente. Sin embargo, la diferencia es que ahora estamos utilizando varias claves de cifrado. Veamos la clase ayudante en acción. Tendrá que proporcionar su $projectId
, $locationId
, $keyRingId
y $cryptoKeyId
, que están disponibles en la consola de Google Cloud.
<?phpuse Google_Service_CloudKMS as Kms;use Google_Service_CloudKMS_DecryptRequest as DecryptRequest;use Google_Service_CloudKMS_EncryptRequest as EncryptRequest;$client = new Google_Client();$client->setAuthConfig(getenv('GOOGLE_CREDENTIALS_FILE'));$client->addScope('https://www.googleapis.com/auth/cloud-platform');$keyManager = new KeyManager( new Kms($client), new EncryptRequest(), new DecryptRequest(), $projectId, $locationId, $keyRingId, $cryptoKeyId);$encrypted = $keyManager->encrypt('This is a secret!');var_dump($encrypted);// array (size=2)// 'data' => string 'uKjmEU7e1JEU+2vL3hBK2wBk6afCSgb+Y4GQtu/mmLuffgHlnqxnqOMPOI6WGkM18vAGGvFVDTvd' (length=76)// 'secret' => string 'CiQAdA0emUW2nhlU3RijX/5GnUsTnPPrQdLZNxdHWXWYugx49a4SSQBHyYr0T/PEbKwyFhIkaZl28oKkJRkXqNcqOL4Z+OTQFLpGvS6zCDt2mFn/nUQ/bi4znD4DORk9ZDTqiIBK3UNFUZcrXvoExds=' (length=152)$decrypted = $keyManager->decrypt($encrypted, $encrypted);var_dump($decrypted);// string 'This is a secret!' (length=17)
Si un atacante pusiera en peligro nuestro sistema, ¿podría obtener también nuestras credenciales de API para Cloud KMS? Dependiendo del método de autenticación utilizado, entonces sí puede ser una posibilidad. Si ese es el caso, es posible que se pregunte cómo el cifrado de sobres es más seguro que el cifrado de clave secreta normal. La diferencia clave (juego de palabras) es que el acceso a la API se puede revocar, lo que frustra a un atacante que se ha fugado con sus datos confidenciales. Es el equivalente a cambiar tus cerraduras si alguien roba la llave de tu casa. Con el cifrado de clave secreta regular donde una sola clave local está comprometida, no tiene ese lujo. El atacante tiene todo el tiempo del mundo para descifrar sus datos confidenciales.
Terminando
La seguridad y el cifrado de datos son temas vastos y solo he cubierto un puñado de formas de proteger datos confidenciales utilizando PHP. (Anteriormente escribimos sobre la protección de este tipo de datos en su entorno local)