Laravel Envelope Encryption with AWS KMS

Resources

https://github.com/bdelamatre/laravel-aws-kms

Intro

If you have stored encrypted data in Laravel than you are familiar with the built-in encryption services. Laravel’s encryption services abstracts PHP’s OpenSSL functions and support AES 128 and AES 256 bit keys. This makes for a simple encryption interface that adheres to recommendations such as utilizing MAC. To make it even easier for you to start using encryption, Laravel will both generate an encryption key (`php artisan key:generate`) and store that key in your .env file as APP_KEY.

The problem

If you have specific compliance goals, you may run into some pitfalls with either the key generation or key storage process used by Laravel. For instance, consider this particular OWASP recommendation about key storage:

“Where available, the secure storage mechanisms provided by the operating system, framework or cloud service provider should be used. These include: … Key vaults such as Amazon KMS … “

OWASP Cheat Sheet

It’s safe to say that Laravel’s default key storage process doesn’t meet this recommendation by OWASP (or other security recommendations). The key is stored encoded with base64 in the local .env file. By not using a purpose-built key storage option as recommended, your risk increases around management of encryption keys. For example, consider this additional recommendation regarding key storage:

“Where possible, encryption keys should be stored in a separate location from encrypted data. For example, if the data is stored in a database, the keys should be stored in the filesystem. “

OWASP Cheat Sheet

Yes, the .env file is stored on your local filesystem and yes, the encrypted values would be limited to the database if that’s where you are storing them. However, you may risk cross-contamination depending on your hosting setup:

  • Have you separated your web and database servers?
  • Do you occasionally backup your database to where the application is hosted?
  • Do you store your file backups and database backups in the same location?
  • Did a developer copy the system to a staging or development environment to troubleshoot?

Things get more complex the more we consider additional OWASP recommendations.

“Where possible, encryption keys should themselves be stored in an encrypted form. At least two separate keys are required for this.”

OWASP Cheat Sheet

Okay, so now we should encrypt APP_KEY using another encryption key, something not supported out of box by Laravel. Some additional considerations might include:

  • Is the key generation process provided by Artisan sufficient?
  • Will you need to eventually rotate keys?
  • How will you share and control key access?
  • Do you really want to spend time managing these considerations?

The solution

In comes AWS’ Key Management Services (KMS). This service is designed to generate and store encryption keys in a highly durable and available service that’s neither our filesystem or our database. With a click of a button we can securely generate a key inside a Hardware Security Module (HSM) which is stored on AWS. We can then interface with that key using AWS’ API and grant access using IAM accounts or roles. This solves the following problems we outlined earlier:

  • The key is generated securely using FIPS hardware security modules
  • The key is stored securely in a purpose-built vault
  • The key can’t accidently leak between the filesystem and database
  • We can control access to the key using IAM

Should we encrypt data directly with AWS KMS?

The biggest decision we will make with KMS is what to encrypt with it.

AWS KMS provides an encrypt method and that method can take arbitrary data. Using this method we can pass small amounts of arbitrary data directly to KMS and it will encrypt that data for us. If we want to have AWS KMS directly encrypt our data, we should consider these limitations:

  • We are limited to a payload size of 4096 bytes.
  • We must transfer all of our encrypted data into KMS
  • We must transfer all of our unencrypted data from KMS

The 4096 byte limitation either works for our application or it doesn’t. If we need to store only secrets then this limitation isn’t likely a concern. If we intend to store large amounts of encrypted data such as payloads, then this may be an instant rule out for whether we can even encrypt data directly with KMS.

Another concern is that all of our data must be transferred into and out of KMS as required by the application. If we have larger amounts of encrypted data or many pieces of encrypted data, the application will need to make larger and more frequent calls to the KMS service which could impact the application’s performance.

Before starting to encrypt data directly with KMS, we should consider if this is the best method. For many applications with simpler requirements, this may be perfectly fine and a step above using Laravel’s built-in encryption service. However, there is a better way.

Envelope encryption for the win

The preferred method of using KMS is with envelope encryption, as outlined in the KMS documentation: https://docs.aws.amazon.com/kms/latest/developerguide/concepts.html#enveloping

This approach follows the OWASP recommendations, is well supported by KMS and doesn’t come with the same concerns as encrypting/decrypting data directly with KMS. Using this method we only encrypt and decrypt the application’s encryption keys with KMS.


        Envelope encryption with multiple key encryption keys

KMS provides API endpoints that will generate data keys, which we then store in our application. Data keys generated by KMS are compatible with Laravel Encrypter. We just need to decrypt the data keys before use so that we can encrypt/decrypt data locally.

Setting up your Customer Master Key (CMK) on KMS

To start with KMS, we need to create a Customer Master Key. Login to the AWS Console and visit the Key Management Service Page. The wizard will walk you through all the steps needed to setup your new CMK and attach a policy to an IAM user. If you need more details on setting up the CMK I would refer you to the AWS Docs for detailed instructions

Once your key is setup, record the ARN for the new CMK. This is the value for AWS_KMS_CMK in the .env file.

The CMK will be used to generate and decrypt Data Encryption Keys (DEKs), which are the keys actually used by our application to encrypt and decrypt our data.

Laravel’s APP_KEY as a Data Encryption Key (DEK)

The simplest approach to adding envelope encryption with KMS to our application is to replace the .env APP_KEY with a DEK generated by KMS. Then on boot, decrypt APP_KEY using KMS and replace the running applications config app.key with the decrypted key. Using this approach we are meeting all the OWASP recommendations for key generation and storage while retaining the simplicity of Laravel’s Encryption service.

“The encrypted DEK can be stored with the data, but will only be usable if an attacker is able to also obtain the KEK, which is stored on another system.”

OWASP CheatSheet

Let’s see an example, luckily I have one on GitHub:

https://github.com/bdelamatre/laravel-aws-kms

You can install this package with Composer.

composer require delamatre/laravel-kms-encryption

Setup your IAM credentials in the .env. Also, set AWS_CMK_ARN with the ARN you recorded while setting up the CMK.

AWS_ACCESS_KEY=MYKEY
AWS_ACCESS_SECRET=MYSECRET
AWS_CMK_ARN=MYCMKARN

Run this Artisan command to generate a new DEK and save as your new APP_KEY.

php artisan kms:generate-data-key --save-as-app-key

You should now have an encrypted KMS data key for your APP_KEY. To test, use this artisan command:

php artisan kms:test
---output---
root@fdde3a30f225:/var/www/html# php artisan kms:test
unencrypted=BoHEDM09RQc04W7m
encrypted=eyJpdiI6InhtMDVxdjE4aDhqQ1BqbEQ4NHFzRlE9PSIsInZhbHVlIjoiM2M0ZDBFRW1lMDJZaXZXK2hLeHdSMU5FaFVUb0MrT1h2d1QzTWNha1VkQT0iLCJtYWMiOiIyMjQ4NDFjYTY5YzBlMzAyN2QzMzE3NjIzMmJiMGJmYTA5ZTk4NDBlMGZkMWE2NzBkNzYxOTI2MGRjZDc2MjFlIn0=
decrypted=BoHEDM09RQc04W7m

That’s it, Laravel will work as documented with a basic envelope encryption setup.

  • APP_KEY in the .env is encrypted
  • The application needs to authorize with AWS KMS to decrypt the encrypted APP_KEY
  • APP_KEY is decrypted once on boot
  • All encryption and decryption is local to the application

How does this work?

After installing `aws/aws-php-sdk`, we need to register KMSClient, which is done in the KmsEncryption/KmsEncryptionServiceProvider. This will allow us to resolve the KMSClient with our applications’s config.

<?php

namespace KmsEncryption;

use Aws\Kms\Exception\KmsException;
use Aws\Kms\KmsClient;
use Closure;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use KmsEncryption\Console\Commands\KmsGenerateDataKey;
use KmsEncryption\Console\Commands\KmsTest;

class KmsEncryptionServiceProvider extends ServiceProvider
{
    public function register()
    {
        // register client
        $this->app->bind(KmsClient::class, function(Application $app, $params = []) {

            return new KmsClient([
                'profile' => $params['profile'] ?? env('AWS_ROLE'),
                'version' => '2014-11-01',
                'region' => $params['region'] ?? env('AWS_DEFAULT_REGION'),
                'credentials' => [
                    'key'    => $params['credentials']['key'] ?? env('AWS_ACCESS_KEY_ID') ?? null,
                    'secret' => $params['credentials']['secret'] ?? env('AWS_SECRET_ACCESS_KEY') ?? null,
                ],
            ]);

        });

        // register commands
        if ($this->app->runningInConsole()) {
            $this->commands([
                KmsGenerateDataKey::class,
                KmsTest::class,
            ]);
        }
    }

}

Next, we need a way to generate a data key using the KMS API. Specifically, we want to call the GenerateDataKeyWithoutPlaintext endpoint. This endpoint will return a DEK encrypted by the CMK, which will become our new APP_KEY. To facilitate this there is an Artisan command at KmsEncryption/Console/Commands/KmsGenerateDataKey we can run. I made the –save-as-app-key an optional flag since it’s potentially dangerous. We format the key with base64 just like the the default Laravel key.

class KmsGenerateDataKey extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'kms:generate-data-key 
                                {--save-as-app-key : Save as APP_KEY in .env} 
                                {--cmk= : Specify the CMK}';
    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Generate a DEK using AWS';
    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }
    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle(KmsClient $kmsClient)
    {
        $saveAsAppKey = $this-&gt;option('save-as-app-key');
        $cmk = $this-&gt;option('cmk') ?? env('AWS_KMS_CMK');
        if(empty($cmk)){
            echo 'No CMK specified. Specify with --cmk or AWS_KMS_CMK environmental variable' . PHP_EOL;
            return 1;
        }
        $dataKey = $kmsClient-&gt;generateDataKeyWithoutPlaintext([
            'KeyId' =&gt; $cmk,
            'KeySpec' =&gt; 'AES_256',
        ])-&gt;get('CiphertextBlob');
        $dataKeyEncoded = 'base64:' . base64_encode($dataKey);
        if($saveAsAppKey){
            $escaped = preg_quote('='.$this-&gt;laravel['config']['app.key'] ?? '', '/');
            file_put_contents($this-&gt;laravel-&gt;environmentFilePath(), preg_replace(
                "/^APP_KEY{$escaped}/m",
                'APP_KEY=' . $dataKeyEncoded,
                file_get_contents($this-&gt;laravel-&gt;environmentFilePath())
            ));
        }
        echo $dataKeyEncoded . PHP_EOL;
        return 0;
    }
}

Next, we need to decrypt the new APP_KEY using KMS when the application boots and use the decrypted key in place of it. Back to KmsEncryption/KmsEncryptionServiceProvider on the boot() method.

<?php

// namespace...

class KmsEncryptionServiceProvider extends ServiceProvider
{
     //public function register() {} ...
    
    public function boot()
    {
        // skip if no cmk configured
        if(!env('AWS_KMS_CMK')){
            return;
        }

        // grab the (kms) encrypted APP_KEY
        $key = config('app.key');

        if (Str::startsWith($key, $prefix = 'base64:')) {
            $key = base64_decode(Str::after($key, $prefix));
        }

        try {

            // instantiate the kms client with default settings
            /** @var KmsClient $client */
            $client = $this->app->make(KmsClient::class);

            // try to decrypt the key using kms
            $decrypted = $client->decrypt([
                'CiphertextBlob' => $key,
                'KeyId' => env('AWS_KMS_CMK'),
            ])->get('Plaintext');

        }catch (KmsException $kmsException){
            return;
        }

        $encoded = 'base64:' . base64_encode($decrypted);

        // override app key with decrypted key
        Config::set('app.key', $encoded);
    }
}

After that, you are all set!

Next Steps

For applications that are storing data for multiple users, we should consider separating our encryption keys per-user / organizational unit. Look for my next post where I extend the above principles and use multiple data encryption keys throughout the application.