Using AWS KMS with Laravel

March 12, 2021

AWS KMS is an incredible offering by AWS that manages encryption keys, automatic rotation and secure storage. With rotation enabled, AWS will generate a new encryption key once a year without deleting the previous keys. Any cipher generated by old keys can still be decrypted. We don't have access to the actual key, which means we can't leak it.

Laravel ships an Encrypter class that uses AES for encryption. Replacing Laravel's implementation with KMS takes only a simple KmsEncrypter and a Service Provider.

The Service Provider

I just sent out a pull request to introduce a StringEncrypter interface into Laravel so that this process can be simplified. https://github.com/laravel/framework/pull/36578

The Service Provider can look like this:

final class EncryptionServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->singleton(KmsEncrypter::class, function () {
            $key = config('encryption.key');
            
            $context = config('encryption.context');

            $client = $this->app->make(KmsClient::class);

            return new KmsEncrypter($client, $key, $context ?? []);
        });

        $this->app->alias(KmsEncrypter::class, 'encrypter');

        $this->app->alias(KmsEncrypter::class, \Illuminate\Contracts\Encryption\Encrypter::class);
        
        $this->app->alias(KmsEncrypter::class, \Illuminate\Contracts\Encryption\StringEncrypter::class);
    }
}

Notice how we're instantiating a KmsClient inside the KmsEncrypter factory callback. That gives us the chance of delegating how we should resolve KmsClient to a separate process. One way of doing that could be like this:

final class AwsServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->registerS3();

        $this->registerKms();
    }

    private function registerS3(): void
    {
        $this->app->bind(S3Client::class, function () {
            $region = config('aws.region');

            return new S3Client(['region' => $region, 'version' => '2006-03-01']);
        });
    }

    private function registerKms(): void
    {
        $this->app->bind(KmsClient::class, fn() => new KmsClient([
            'version' => '2014-11-01',
            'region' => config('aws.region'),
        ]));
    }
}

KmsEncrypter

Here's how KmsEncrypter would look like:

final class KmsEncrypter implements Encrypter, StringEncrypter
{
    public function __construct(private KmsClient $client, private string $key, private array $context) {}

    public function encrypt($value, $serialize = true)
    {
        try {
            return base64_encode($this->client->encrypt([
                'KeyId' => $this->key,
                'Plaintext' => $value,
                'EncryptionContext' => $this->context,
            ])->get('CiphertextBlob'));
        } catch (AwsException $e) {
            throw new EncryptException($e->getMessage(), $e->getCode(), $e);
        }
    }

    public function decrypt($payload, $unserialize = true)
    {
        try {
            $result = $this->client->decrypt([
                'CiphertextBlob' => base64_decode($payload),
                'EncryptionContext' => $this->context,
            ]);

            return $result['Plaintext'];
        } catch (AwsException $e) {
            throw new DecryptException($e->getMessage(), $e->getCode(), $e);
        }
    }

    public function encryptString(string $value): string
    {
        return $this->encrypt($value, false);
    }

    public function decryptString(string $value): string
    {
        return $this->decrypt($value, false);
    }
}

Let's dissect this class. First we have the KmsClient that already carries every configuration necessary to interact with AWS KMS. Whether you use environment variable with AWS Credentials or let your AWS service assume role with permission to interact with KMS, AWS SDK will handle the authentication. Then we have the key which should be the ARN of the AWS KMS key or Alias of the key to be used. Finally we have context. Context is a non-secret information (e.g. it will be plain text on CloudTrail) that allows you to bind your encryption with a specific signing context. If the exact same context is not provided when trying to decrypt a specific cipher text, then the decryption will not work. For instance, you may choose to use your service name as the context of your encryption so that if other services accidentally tries to decrypt your cipher texts, it won't just work. The developer would have to make a conscious decision to specify another service's context when trying to decrypt cross-service data.

For ease of use, we can base64_encode() the cipher text and then base64_decode before decryption so that it's easier to pass the data around. If you're interested in learning more about base64_encode, check out my post on Should I encrypt, hash or encode?.

Eloquent

Since the ServiceProvider is replacing the binding behind encrypter on Laravel's service provider, we're free to use Eloquent's cast feature to encrypt/decrypt attributes automatically.

final class Credential extends TenantModel
{
    protected $casts = [
        'password' => 'encrypted',
        'client_secret' => 'encrypted',
    ];
}

This way whenever we try to save something into the password or client_secret attributes, Eloquent will use KmsEncrypter to encrypt the data being stored and when we're accessing the attribute, Eloquent Mutators will kick in and decrypt it.

Tests

For my automation tests, I decided to use a NullEncrypter implementation so that I don't need to integrate directly with AWS KMS to run tests. Here's how a NullEncrypter could look like:

final class NullEncrypter implements Encrypter
{
    public function encryptString(string $value): string
    {
        return $this->encrypt($value, false);
    }

    public function decryptString(string $value): string
    {
        return $this->decrypt($value, false);
    }

    public function encrypt($value, $serialize = true)
    {
        return $value;
    }

    public function decrypt($payload, $unserialize = true)
    {
        return $payload;
    }
}

On test could, we could then replace the binding like this:

$this->app->bind(Encrypter::class, NullEncrypter::class);
$this->app->bind(StringEncrypter::class, NullEncrypter::class);
$this->app->bind('encrypter', NullEncrypter::class);

Watch out for your storage service

During the development of this implementation, I first wrote a produt feature without encryption and handed the APIs over to the frontend team so that they could get started. I then started implementing AWS KMS encryption. I noticed that anytime my code would try to decrypt a cipher text, it would throw an exception saying

Error executing "Decrypt" on "https://kms.eu-west-1.amazonaws.com";
AWS HTTP error: Client error: `POST https://kms.eu-west-1.amazonaws.com` resulted in a `400 Bad Request` response:
{"__type":"InvalidCiphertextException"}
InvalidCiphertextException (client):  - {"__type":"InvalidCiphertextException"}

The reason I was getting this error was not because of the key nor the context. It was actually because I was interacting with a legacy database with strict=false and a password field of type varchar(191). What would then happen was that the cipher text would go above 191 characters and MySQL would truncate the data down to 191 characters. Losing part of a cipher text means that we did not guarantee the integrity of the message and we can no longer decrypt it. Increasing the field size mitigated the issue.

Why don't we inform the key to the decrypt API call to AWS?

When AWS encrypts a payload, it puts inside the cipher text an identifier for which key was used for encryption. That way even if we don't know which key was used for encryption, AWS can still decrypt it as long as you have the complete cipher text and the context. This probably facilitates AWS's job when rotating keys. When the key reaches 1 year of life, AWS will generate a brand new one and start using it for any new encryption. It won't get rid of the old key, though. So if you make an API call asking for decryption with a cipher text older than 1 year, AWS can still find the identifier for the key used and decrypt it.

Defining a Key with CloudFormation

The following template defines a MyEncryptionKey resource and a MyEncryptionKeyAlias resource. It will also output the alias alongside the Key ARN. The Key ARN can be used to attach to an IAM Role that will need kms:Encrypt* and kms:Decrypt*. The Alias can be used as an Environment Variable for your compute resource so that it can be accessed by Laravel and injected into the KmsEncrypter class.

AWSTemplateFormatVersion: "2010-09-09"
Description: AWS KMS

Resources:
  MyEncryptionKey:
    Type: AWS::KMS::Key
    Properties:
      KeyUsage: ENCRYPT_DECRYPT
      Description: Encryption key used for Encrypting/Decrypting sensitive data
      EnableKeyRotation: true
      KeyPolicy:
        Version: '2012-10-17'
        Id: EncryptionKey
        Statement:
          - Sid: Enable IAM Permissions
            Effect: Allow
            Principal:
              AWS:
                - !Sub "arn:aws:iam::${AWS::AccountId}:root"
            Action: kms:*
            Resource: '*'

  MyEncryptionKeyAlias:
    Type: AWS::KMS::Alias
    Properties:
      AliasName: alias/my-key-alias
      TargetKeyId: !GetAtt GeneralEncryptionKey.Arn

Outputs:
  MyEncryptionKeyAlias:
    Description: Alias of Encryption key used for Encrypting/Decrypting sensitive data
    Value: !Ref MyEncryptionKeyAlias
    Export:
      Name: MyEncryptionKeyAlias

  MyEncryptionKeyArn:
    Description: ARN of Encryption key used for Encrypting/Decrypting sensitive data
    Value: !GetAtt MyEncryptionKeyArn.Arn
    Export:
      Name: MyEncryptionKeyArn

Conclusion

AWS KMS offering is great for the enterprise world. We will never leak keys, they will be rotated automatically and it costs pennies for the benefit that they bring. Laravel's Encrypter and StringEncrypter interfaces makes it easy to swap the implementation and offer a great DX to work with encryption be it directly or via Eloquent. All in all it's a great service, easy to use and designed to offer safety.

It's important to note that I'm not rolling my own encryption here. I'm swapping Laravel's Encrypter class with one that uses AWS KMS. In other words, AWS KMS is responsible for encryption/decryption of the data and we should never roll our own encryption algorithms.

Follow me on Twitter to stay tuned with my latest work.

Cheers.