PHP Encryption Methods for Passwords & Other Sensitive Data

#

I recently attended Laracon EU 2018 where Marcus Bointon gave a great talk on Crypto in PHP 7.2. I left the talk having a much greater appreciation for how vastly complicated cryptography is, but also for how PHP is making encryption more accessible thanks to the introduction of Sodium. Data encryption in PHP is something I’ve been working on as part of my work on SpinupWP so I thought it was time I shared a few insights. Buckle up, because this could be a bumpy ride!

Types of Encryption

There are a range of different encryption methods in use today, the most common being hashing, secret key encryption and public key encryption. In addition, each encryption method has multiple algorithms or ciphers to choose from (each with their own strengths and weaknesses). In this article we’re going to focus on implementing hashing and secret key encryption.

Hashing

A hashing algorithm takes an input value and transforms it to a message digest. In a nutshell, plaintext values are transformed to a fixed length hash, and can only be validated by passing the original value to the hashing algorithm. This makes hashing perfect for storing user passwords.

It’s worth noting that hashing isn’t a bulletproof solution and not all hashing algorithms are equal. Consider MD5 and SHA1 which are fast and efficient, making them ideal for checksumming and file verification. However, their speed makes them unsuitable for hashing a user’s password. With today’s computational power of modern GPUs, a password can be cracked by brute force in a matter of minutes, revealing the original plaintext password. Instead, intentionally slower hashing algorithms such as bcrypt or Argon2 should be used.

While a hashed password generated by any algorithm will certainly obscure the original data and slow down any would-be attacker, we as developers should strive to use the strongest algorithm available. Luckily, PHP makes this easy thanks to password_hash().

$hash = password_hash($password, PASSWORD_DEFAULT);

The password_hash() function not only uses a secure one-way hashing algorithm, but it automatically handles salt and prevents time based side-channel attacks. As of PHP 5.5, bcrypt will be used to generate the hash, but this will change in the future as newer and more secure hashing algorithms are added to PHP. Argon2 is likely to become the next default hashing algorithm and can be used today (on PHP 7.2) by passing the PASSWORD_ARGON2I flag instead of PASSWORD_DEFAULT.

Verifying a user’s password is also a trivial process thanks to the password_verify() function. Simply pass the plaintext password supplied by the user and compare it to the stored hash, like so:

if (password_verify($password, $hash)) {
    echo "Let me in, I'm genuine!";
}

Notice how the password verification is performed in PHP. If you’re storing a user’s credentials in a database you may be inclined to hash the password entered at login and then perform a database query, like so:

SELECT * FROM users
WHERE username = 'Ashley'
AND password = 'password_hash'
LIMIT 1;

This approach is susceptible to side-channel attacks and should be avoided. Instead, return the user and then check the password hash in PHP.

SELECT username, password FROM users
WHERE username = 'Ashley'
LIMIT 1;

While hashing is great for storing a user’s password, it doesn’t work for arbitrary data that our application needs to access without user intervention. Let’s consider a billing application, which encrypts a user’s credit card information. Each month our application needs to bill the user for their previous month’s usage. Hashing the credit card data won’t work because it requires that our application knows the original data for it retrieve it in plaintext.

Secret key encryption to the rescue!

Secret Key Encryption

Secret key encryption (or symmetric encryption as it’s also known) uses a single key to both encrypt and decrypt data. Let’s see how we would implement such a mechanism using Sodium, which was introduced in PHP 7.2. If you’re running an older version of PHP you can install sodium via PECL.

First we need an encryption key, which can be generated using the random_bytes() function. Usually, you’ll do this only once and store it as an environment variable. Remember that this key must be kept secret at all costs. Once the key is compromised, so is any encrypted data.

$key = random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);

To encrypt the value we pass it to sodium_crypto_secretbox() with our $key and a $nonce. The nonce is generated using random_bytes(), because the same nonce should never be reused.

$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = sodium_crypto_secretbox('This is a secret!', $nonce, $key);

This presents a problem because we need the nonce to decrypt the value later. Luckily, nonces don’t have to be kept secret so we can prepend it to our $ciphertext then base64_encode() the value before saving it to the database.

$encoded = base64_encode($nonce . $ciphertext);
var_dump($encoded);

// string 'v6KhzRACVfUCyJKCGQF4VNoPXYfeFY+/pyRZcixz4x/0jLJOo+RbeGBTiZudMLEO7aRvg44HRecC' (length=76)

When it comes to decrypting the value, we do the opposite.

$decoded = base64_decode($encoded);

Because we know the length of nonce we can extract it using mb_substr() before decrypting the value.

$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)

That’s all there is to secret key encryption in PHP, thanks to Sodium!

Envelope Encryption

While the approach outlined above is certainly a step in the right direction, it still leaves our data vulnerable if the secret key is compromised. Let’s consider a malicious user that gains access to the server that hosts our application. In this scenario, chances are the attacker will be able to discover our secret key which we used to encrypt the data. This leaves our data completely exposed.

The simple solution is to not store our secret key in the same location as the encrypted data, but this presents a problem. How do we encrypt and decrypt on demand? Enter Google Cloud Key Management Service (Cloud KMS).

Cloud KMS is a service provided by Google for securely hosting cryptographic keys. It provides a variety of useful features around key storage, including automatic key rotation and delayed key destruction. However, in this example we’re primarily concerned with storing our secret key away from our data.

To make things more secure we’re going to use a technique known as envelope encryption. Essentially, envelope encryption involves encrypting keys with another key. We do this for two reasons:

  1. Cloud KMS has a size limit of 64 KiB on the data that can be encrypted and decrypted. Therefore, it may not be possible to send all of the data in one fell swoop.
  2. More importantly we don’t want to send our sensitive plaintext data to a third party, regardless of how trustworthy they may seem.

Instead of sending our plaintext data to Cloud KMS, we’re going to generate a unique encryption key every time we write sensitive data to the database. This key is known as a data encryption key (DEK), which will be used to encrypt our data. The DEK is then sent to Cloud KMS to be encrypted, which returns a key encryption key (known as a KEK). Finally, the KEK is stored side-by-side in the database next to the encrypted data and the DEK is destroyed. The process looks like so:

  1. Generate a unique encryption key (DEK)
  2. Encrypt the data using secret key encryption
  3. Send the unique encryption key (DEK) to Cloud KMS for encryption, which returns the KEK
  4. Store the encrypted data and encrypted key (KEK) side-by-side
  5. Destroy the generated key (DEK)

When decrypting data the process is reversed:

  1. Retrieve the encrypted data and encrypted key (KEK) from the database
  2. Send the KEK to Cloud KMS for decryption, which returns the DEK
  3. Use the DEK to decrypt our encrypted data
  4. Destroy the DEK

With this in mind I’ve created a very simple helper class for performing envelope encryption. I’m not going to go over the steps required in the Google Cloud console, as the Quickstart and Authentication guides outline everything you need to get started. For the sake of brevity there’s no error handling etc. in this example.

<?php

use 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 [
            'data'   => base64_encode($nonce . $ciphertext),
            'secret' => $this->encryptKey($key),
        ];
    }

    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['ciphertext'];
    }

    private function decryptSecret($secret)
    {
        $this->decryptRequest->setCiphertext($secret);

        $response = $this->kms->projects_locations_keyRings_cryptoKeys->decrypt(
            $this->getResourceName(),
            $this->decryptRequest
        );

        return base64_decode($response['plaintext']);
    }

    private function getResourceName()
    {
        return sprintf(
            'projects/%s/locations/%s/keyRings/%s/cryptoKeys/%s',
            $this->projectId,
            $this->locationId,
            $this->keyRingId,
            $this->cryptoKeyId
        );
    }
}

You’ll notice that the actual encryption and decryption methods are almost identical to the secret key implementation introduced above. The difference however is that we’re now using multiple encryption keys. Let’s see the helper class in action. You’ll need to provide your $projectId, $locationId, $keyRingId and $cryptoKeyId which are available from the Google Cloud console.

<?php

use 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['secret'], $encrypted['data']);
var_dump($decrypted);

// string 'This is a secret!' (length=17)

If an attacker compromised our system, would they be able to also gain our API credentials for Cloud KMS? Depending on the authentication method used, then yes it may be a possibility. If that’s the case, you may be wondering how envelope encryption is any more secure than regular secret key encryption? The key difference (pun intended) is that API access can be revoked, thus thwarting an attacker that’s made off with your sensitive data. It’s the equivalent of changing your locks if someone steals your house key. With regular secret key encryption where a single local key is compromised you don’t have that luxury. The attacker has all the time in the world to decrypt your sensitive data.

Wrapping Up

Data security and encryption are vast subjects and I’ve covered only a handful of ways to protect sensitive data using PHP. (We previously wrote about protecting this kind of data in your local environment)

What precautions do you take? Let us know in the comments below. And remember, never save your DEK locally!

About the Author

Ashley Rich

Ashley is a PHP and JavaScript developer with a fondness for hosting, server performance and security. Before joining Delicious Brains, Ashley served in the Royal Air Force as an ICT Technician.