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