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.