Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[5.5] Add GCM support to encrypter #21963

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 157 additions & 28 deletions src/Illuminate/Encryption/Encrypter.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@

class Encrypter implements EncrypterContract
{
/**
* The supported cipher algorithms and their settings.
*
* @var array
*/
private static $supportedCiphers = [
'aes-128-cbc' => ['size' => 16, 'aead' => false],
'aes-256-cbc' => ['size' => 32, 'aead' => false],
'aes-128-gcm' => ['size' => 16, 'aead' => true, 'since' => '7.1.0'],
'aes-256-gcm' => ['size' => 32, 'aead' => true, 'since' => '7.1.0'],
];

/**
* The encryption key.
*
Expand All @@ -23,6 +35,13 @@ class Encrypter implements EncrypterContract
*/
protected $cipher;

/**
* Whether the cipher is AEAD cipher.
*
* @var bool
*/
protected $aead;

/**
* Create a new encrypter instance.
*
Expand All @@ -35,12 +54,15 @@ class Encrypter implements EncrypterContract
public function __construct($key, $cipher = 'AES-128-CBC')
{
$key = (string) $key;
$cipher = strtolower($cipher);

if (static::supported($key, $cipher)) {
$this->key = $key;
$this->cipher = $cipher;
$this->aead = self::$supportedCiphers[$this->cipher]['aead'];
} else {
throw new RuntimeException('The only supported ciphers are AES-128-CBC and AES-256-CBC with the correct key lengths.');
$ciphers = implode(', ', $this->getAvailableCiphers());
throw new RuntimeException("The only supported ciphers are $ciphers with the correct key lengths.");
}
}

Expand All @@ -53,10 +75,18 @@ public function __construct($key, $cipher = 'AES-128-CBC')
*/
public static function supported($key, $cipher)
{
$length = mb_strlen($key, '8bit');
if (! isset(self::$supportedCiphers[$cipher])) {
return false;
}

$cipherSetting = self::$supportedCiphers[$cipher];
if (isset($cipherSetting['since']) &&
version_compare(PHP_VERSION, $cipherSetting['since'], '<')
) {
return false;
}

return ($cipher === 'AES-128-CBC' && $length === 16) ||
($cipher === 'AES-256-CBC' && $length === 32);
return mb_strlen($key, '8bit') === $cipherSetting['size'];
}

/**
Expand All @@ -67,7 +97,7 @@ public static function supported($key, $cipher)
*/
public static function generateKey($cipher)
{
return random_bytes($cipher == 'AES-128-CBC' ? 16 : 32);
return random_bytes(self::$supportedCiphers[$cipher]['size'] ?? 32);
}

/**
Expand All @@ -83,13 +113,56 @@ public function encrypt($value, $serialize = true)
{
$iv = random_bytes(openssl_cipher_iv_length($this->cipher));

if ($serialize) {
$value = serialize($value);
}
$json = json_encode(
$this->aead ? $this->encryptAead($value, $iv) : $this->encryptMac($value, $iv)
);

if (json_last_error() !== JSON_ERROR_NONE) {
throw new EncryptException('Could not encrypt the data.');
}

return base64_encode($json);
}

/**
* Encrypt value using AEAD cipher.
*
* @param string $value
* @param string $iv
* @return array
*/
protected function encryptAead($value, $iv)
{
// We will encrypt AEAD ciphers which will give us authentication tag.
$value = openssl_encrypt($value, $this->cipher, $this->key, 0, $iv, $tag);

if ($value === false) {
throw new EncryptException('Could not encrypt the data.');
}

return [
'iv' => base64_encode($iv),
'value' => $value,
'tag' => base64_encode($tag),
];
}

/**
* Encrypt value using non AEAD cipher and MAC.
*
* @param string $value
* @param string $iv
* @return array
*/
protected function encryptMac($value, $iv)
{
// First we will encrypt the value using OpenSSL. After this is encrypted we
// will proceed to calculating a MAC for the encrypted value so that this
// value can be verified later as not having been changed by the users.
$value = \openssl_encrypt(
$serialize ? serialize($value) : $value,
$this->cipher, $this->key, 0, $iv
);
$value = openssl_encrypt($value, $this->cipher, $this->key, 0, $iv);

if ($value === false) {
throw new EncryptException('Could not encrypt the data.');
Expand All @@ -100,13 +173,7 @@ public function encrypt($value, $serialize = true)
// its authenticity. Then, we'll JSON the data into the "payload" array.
$mac = $this->hash($iv = base64_encode($iv), $value);

$json = json_encode(compact('iv', 'value', 'mac'));

if (json_last_error() !== JSON_ERROR_NONE) {
throw new EncryptException('Could not encrypt the data.');
}

return base64_encode($json);
return compact('iv', 'value', 'mac');
}

/**
Expand Down Expand Up @@ -134,19 +201,62 @@ public function decrypt($payload, $unserialize = true)
$payload = $this->getJsonPayload($payload);

$iv = base64_decode($payload['iv']);
$decrypted = $this->aead
? $this->decryptAead($payload, $iv)
: $this->decryptMac($payload, $iv);

return $unserialize ? unserialize($decrypted) : $decrypted;
}

/**
* Decrypt value using AEAD cipher.
*
* @param array $payload
* @param string $iv
* @return string
*/
protected function decryptAead($payload, $iv)
{
// Here we will decrypt the value. If we are able to successfully decrypt it
// we will then unserialize it and return it out to the caller. If we are
// unable to decrypt this value we will throw out an exception message.
$decrypted = \openssl_decrypt(
// we will return it. If we are nable to decrypt this value, it means that the tag
// is invalid and we will throw out an exception message.
$decrypted = openssl_decrypt(
$payload['value'], $this->cipher, $this->key, 0, $iv, base64_decode($payload['tag'])
);

if ($decrypted === false) {
throw new DecryptException('The authentication tag is invalid.');
}

return $decrypted;
}

/**
* Decrypt value using non AEAD cipher and MAC.
*
* @param array $payload
* @param string $iv
* @return string
*/
protected function decryptMac($payload, $iv)
{
// First we will check if the MAC is valid
if (! $this->validMac($payload)) {
throw new DecryptException('The MAC is invalid.');
}

// Here we will decrypt the value. If we are able to successfully decrypt it
// we will return it. If we are unable to decrypt this value we will throw out
// an exception message (this should however never happen for AES CBC mode).
$decrypted = openssl_decrypt(
$payload['value'], $this->cipher, $this->key, 0, $iv
);

if ($decrypted === false) {
throw new DecryptException('Could not decrypt the data.');
}

return $unserialize ? unserialize($decrypted) : $decrypted;
return $decrypted;
}

/**
Expand Down Expand Up @@ -186,15 +296,11 @@ protected function getJsonPayload($payload)

// If the payload is not valid JSON or does not have the proper keys set we will
// assume it is invalid and bail out of the routine since we will not be able
// to decrypt the given value. We'll also check the MAC for this encryption.
// to decrypt the given value.
if (! $this->validPayload($payload)) {
throw new DecryptException('The payload is invalid.');
}

if (! $this->validMac($payload)) {
throw new DecryptException('The MAC is invalid.');
}

return $payload;
}

Expand All @@ -206,9 +312,13 @@ protected function getJsonPayload($payload)
*/
protected function validPayload($payload)
{
return is_array($payload) && isset(
$payload['iv'], $payload['value'], $payload['mac']
);
if (! is_array($payload)) {
return false;
}

return $this->aead
? isset($payload['iv'], $payload['value'], $payload['tag'])
: isset($payload['iv'], $payload['value'], $payload['mac']);
}

/**
Expand Down Expand Up @@ -240,6 +350,25 @@ protected function calculateMac($payload, $bytes)
);
}

/**
* Get available ciphers.
*
* @return array
*/
private function getAvailableCiphers()
{
$availableCiphers = [];
foreach (self::$supportedCiphers as $cipherName => $setting) {
if (! isset($setting['since']) ||
version_compare(PHP_VERSION, $setting['since'], '>=')
) {
$availableCiphers[] = strtoupper($cipherName);
}
}

return $availableCiphers;
}

/**
* Get the encryption key.
*
Expand Down
28 changes: 24 additions & 4 deletions tests/Encryption/EncrypterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,19 @@ public function testWithCustomCipher()
$this->assertEquals('foo', $e->decrypt($encrypted));
}

public function testAeadCipher()
{
$this->onlyForAead();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just use the @requires PHP 7.1 annotation.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And drop the extra method. :)


$e = new Encrypter(str_repeat('b', 32), 'AES-256-GCM');
$encrypted = $e->encrypt('bar');
$this->assertNotEquals('bar', $encrypted);
$this->assertEquals('bar', $e->decrypt($encrypted));
}

/**
* @expectedException \RuntimeException
* @expectedExceptionMessage The only supported ciphers are AES-128-CBC and AES-256-CBC with the correct key lengths.
* @expectedExceptionMessageRegExp /The only supported ciphers are (AES-\d{3}-[A-Z]{3}(, )?)+ with the correct key lengths./
*/
public function testDoNoAllowLongerKey()
{
Expand All @@ -55,7 +65,7 @@ public function testDoNoAllowLongerKey()

/**
* @expectedException \RuntimeException
* @expectedExceptionMessage The only supported ciphers are AES-128-CBC and AES-256-CBC with the correct key lengths.
* @expectedExceptionMessageRegExp /The only supported ciphers are (AES-\d{3}-[A-Z]{3}(, )?)+ with the correct key lengths./
*/
public function testWithBadKeyLength()
{
Expand All @@ -64,7 +74,7 @@ public function testWithBadKeyLength()

/**
* @expectedException \RuntimeException
* @expectedExceptionMessage The only supported ciphers are AES-128-CBC and AES-256-CBC with the correct key lengths.
* @expectedExceptionMessageRegExp /The only supported ciphers are (AES-\d{3}-[A-Z]{3}(, )?)+ with the correct key lengths./
*/
public function testWithBadKeyLengthAlternativeCipher()
{
Expand All @@ -73,7 +83,7 @@ public function testWithBadKeyLengthAlternativeCipher()

/**
* @expectedException \RuntimeException
* @expectedExceptionMessage The only supported ciphers are AES-128-CBC and AES-256-CBC with the correct key lengths.
* @expectedExceptionMessageRegExp /The only supported ciphers are (AES-\d{3}-[A-Z]{3}(, )?)+ with the correct key lengths./
*/
public function testWithUnsupportedCipher()
{
Expand Down Expand Up @@ -102,4 +112,14 @@ public function testExceptionThrownWithDifferentKey()
$b = new Encrypter(str_repeat('b', 16));
$b->decrypt($a->encrypt('baz'));
}

/**
* Run test only for AEAD.
*/
private function onlyForAead()
{
if (version_compare(PHP_VERSION, '7.1', '<')) {
$this->markTestSkipped('The AEAD is not supported in PHP 7.0');
}
}
}