Encrypt in Rails, decrypt in Laravel
On a recent project I have to make a request from a Rails app to a Laravel app. The Laravel app requires payload data to be encrypted and serialized in PHP style. While the situation is not ideal, I have interesting time working on the issue and learn about Laravel encryption and a few PHP functions.
Laravel uses Illuminate Encrypter for encrypt/decrypt purposes. Let look at the code for this encrypter/decrypter available at https://github.com/laravel/framework/blob/5.5/src/Illuminate/Encryption/Encrypter.php
I copy here the encrypt and decrypt functions, the main functions of the class:
/**
* Encrypt the given value.
*
* @param mixed $value
* @param bool $serialize
* @return string
*
* @throws \Illuminate\Contracts\Encryption\EncryptException
*/
public function encrypt($value, $serialize = true)
{
$iv = random_bytes(openssl_cipher_iv_length($this->cipher));
// 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
);
if ($value === false) {
throw new EncryptException('Could not encrypt the data.');
}
// Once we get the encrypted value we'll go ahead and base64_encode the input
// vector and create the MAC for the encrypted value so we can then verify
// 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);
}
/**
* Decrypt the given value.
*
* @param mixed $payload
* @param bool $unserialize
* @return string
*
* @throws \Illuminate\Contracts\Encryption\DecryptException
*/
public function decrypt($payload, $unserialize = true)
{
$payload = $this->getJsonPayload($payload);
$iv = base64_decode($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(
$payload['value'], $this->cipher, $this->key, 0, $iv
);
if ($decrypted === false) {
throw new DecryptException('Could not decrypt the data.');
}
return $unserialize ? unserialize($decrypted) : $decrypted;
}
Reading this Encrypter class gives me rough ideas on how the encryption/decryption work. So the real task here is to replicate the encrypt
function in Rails.
I was provided with an APP_KEY
with the format base64:xxx..
. A quick search reveals this is used as secret key in Laravel app config similar to Rails secret key. And next to it in app config is cipher
. So now I know the key and cipher used in the encryption process.
// config/app.php
'key' => env('APP_KEY'),
'cipher' => 'AES-256-CBC',
Also from a quick scan through the functions we could see that the encrypter uses OpenSSL for encryption/descryption purposes and all of those encrypt/descrypt functions are available in PHP crypto extensions (see https://www.php.net/manual/en/ref.openssl.php)
Let move into the first line of encrypt
function:
$iv = random_bytes(openssl_cipher_iv_length($this->cipher));
This line checks for cipher initialization vector iv
length. Basically if cipher === 'AES-256-CBC'
iv length should be 32 characters, and if cipher === 'AES-128-CBC'
it should be 16 characters. Then it generates a sequence of random bytes that match with the cipher iv length.
The equivalent for the line above in Ruby should be:
require 'openssl'
cipher = OpenSSL::Cipher.new('AES-256-CBC')
cipher.encrypt
cipher.iv = cipher.random_iv
Move to the next line
$value = \openssl_encrypt(
$serialize ? serialize($value) : $value,
$this->cipher, $this->key, 0, $iv
);
If we look into the function arguments, by default $serialize
is true, that means we need to serialize input. However serialization in Ruby and PHP are different. For example:
# A hash in Ruby
{
foo: 'bar',
baz: 'bazz'
}
# equivalent in PHP
[
"foo" => "bar",
"baz" => "bazz"
]
A quick solution to this is to use PHP serialize gem which wrap the PHP searialize
and unserialize
functions https://github.com/jqr/php-serialize
Also, we should notice the 0
value in the openssl_encrypt
arguments. It is described as options
in PHP manual:
// https://www.php.net/manual/en/function.openssl-encrypt.php
options
options is a bitwise disjunction of the flags OPENSSL_RAW_DATA and OPENSSL_ZERO_PADDING.
The description is less than useful and after searching for explaination 0
is default options value which will encode the result in Base64.
The encrypt function also requires secret key. As mentioned above Laravel has APP_KEY
with format base64:xxx...
. This key is encoded with Base64 and added base64:
as prefix. So the equivalent step in Ruby should be as follow:
cipher.key = Base64.strict_decode64('xxx...')
serialized_input = PHP.serialize(input)
encrypted = Base64.strict_encode64(cipher.update(serialized_input) + cipher.final)
Skip the error checking lines and move to mac creation line:
// Once we get the encrypted value we'll go ahead and base64_encode the input
// vector and create the MAC for the encrypted value so we can then verify
// its authenticity. Then, we'll JSON the data into the "payload" array.
$mac = $this->hash($iv = base64_encode($iv), $value);
Let's check Encrypter's hash
function:
return hash_hmac('sha256', $iv.$value, $this->key);
So this is a HMAC hash function using sha256
algo. The equivalent in Ruby would be:
mac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), cipher.key, cipher.iv + encrypted)
Finally the Encrypter's encrypt
function last lines are about creating a JSON and return a Base64 encoded version of that JSON
$json = json_encode(compact('iv', 'value', 'mac'));
return base64_encode($json);
This equals to:
hash = { iv: iv, value: encrypted, mac: mac }
Base64.strict_encode64(hash.to_json)
Here is the complete implemention of encryption method in Ruby:
require 'openssl'
require 'base64'
class LaravelEncrypter
KEY = Base64.strict_decode64('xxx...')
CIPHER_METHOD = 'AES-256-CBC'
def self.encrypt(input)
cipher = OpenSSL::Cipher.new(CIPHER_METHOD)
cipher.encrypt
cipher.iv = cipher.random_iv
cipher.key = KEY
serialized_input = PHP.serialize(input)
encrypted = Base64.strict_encode64(cipher.update(serialized_input) + cipher.final)
mac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'),
cipher.key, Base64.strict_encode64(cipher.iv) + encrypted)
hash = { iv: iv, value: encrypted, mac: mac }
Base64.strict_encode64(hash.to_json)
end
end
Another note on why we need to encode encrypted value in Base64, it's not because of default options value in PHP but because often there is a good chance that encrypted value will contain byte char which is not supported by JSON.
References