Published 16/08/2021
Last Updated 20/11/2024

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: