Implementing Key Wrapping in Elixir with KMS
Published on Mar 27, 2021 by dix.
Recently, I was part of a team implementing a payments system in Elixir deployed on AWS. A payments system has to handle sensitive data that should not be stored in plaintext but needs to be accessed in plaintext after it is first stored. For example, credit card numbers, bank account numbers should not be stored in plaintext but the system needs access to the plaintext in order to process transactions. Based on our team’s experience building payments systems in other languages, we wanted to implement a data encryption scheme which provided key-wrapping of data-encryption keys and easy automated rotation of data encryption keys.
Key wrapping (also referred to as Envelope encryption) is the practice of using a hierarchy of keys to encrypt your systems data. One key, the data-encryption key is used to encrypt sensitive data in your system. This data-encryption key is stored encrypted alongside the data it encrypts. A second key, the key-encryption key is used to decrypt the data-encryption key before an application can use it. The key-encryption key is stored in a key management system and can’t be accessed directly. The key management service itself can be subject to a higher level of security and audit than individual applications.1
Key rotation is the practice of re-encrypting sensitive data using new encryption keys on a regular basis. Regular rotation of keys produces two main benefits. When encryption keys are regularly rotated, the quantity of data compromised by the leak of any single data-encryption key is minimized. Additionally, the regular rotation of keys limits the amount of encrypted data generated by any single key. The more encrypted data an attacker can gather, the more able the attacker is to uncover the encryption key through cryptanalysis.
We found that by combining tools provided by AWS and tools in the the Elixir ecosystem, we had the right building blocks to implement this encryption scheme. However, we had to tie them together in ways that weren’t already documented. I hope this post can serve as a resource to others looking to implement such a scheme and perhaps as a motivation to the maintainers of the Elixir encryption ecosystem to make it easier for users to implement stronger encryption systems.
Implementation
To implement our database encryption scheme, we made use of the AWS Key
Management Service (KMS)2 and a couple of Elixir libraries.
cloak_ecto
3 is an Elixir library that uses cloak
4, an
Elixir encryption library, to provide column level encryption to ecto
,
an Elixir toolkit for interacting with databases. cloak_ecto
provided
many of the features we wanted, but did not suppport key-wrapping out of
the box. 5 We also made use of ex_aws
6 and
ex_aws_kms
7, Elixir libraries for interacting with AWS APIs.
cloak_ecto
allows specifying any cloak
vault8 to be used to
encrypt and decrypt data. We implemented our own vault that would pull
its keys from a KeySource
. KeySource
was a behaviour we specified as
follows:
defmodule Encryption.KeySource do @callback ciphers(DateTime.t() | nil) :: keyword() end
Our custom vault would pull keys from a KeySource
depending on how our
application was configured.
defmodule Encryption.Vault do use Cloak.Vault, otp_app: :encryption @impl GenServer def init(config) do config = Keyword.put(config, :ciphers, keysource().ciphers()) {:ok, config} end defp keysource do Application.get_env(:encryption, :keysource, Encryption.KeySource.Local) end end
In non-deployed environments, we didn’t want to rely on AWS being
available so we used a static decrypted data-encryption key. This led to
a very simple KeySource
defmodule Encryption.KeySource.Local do @behaviour Encryption.KeySource @impl Encryption.KeySource def ciphers(_ \\ DateTime.utc_now()) do [ default: {Cloak.Ciphers.AES.GCM, tag: "AES.GCM.V1", key: Base.decode64!("<a base64 encoded key>")} ] end end
In our deployed environments, we had a more complicated KeySource
. In
these environments, the KeySource
queried the datbase for encrypted
data-encryption keys and decrypted them using AWS KMS. These keys could
then be provided for use in the cloak
vault. In addition, this
KeySource
determined if a new key needed to be created, either because
no data encryption keys exist or it is time to rotate data encryption
keys.
defmodule Encryption.KeySource.Kms do @behaviour Encryption.KeySource require Ecto.Query require Logger alias Encryption.Model.DataEncryptionKey @impl Encryption.KeySource def ciphers(current_time \\ DateTime.utc_now()) do min_inserted_at = DateTime.add(current_time, -max_data_encryption_key_age()) new_enough_ciphers = DataEncryptionKey |> Ecto.Query.where( [c], c.inserted_at > ^min_inserted_at ) |> Database.Repo.all() if Enum.empty?(new_enough_ciphers) do generate_new_data_encryption_key() end DataEncryptionKey |> Ecto.Query.order_by(desc: :inserted_at) |> Database.Repo.all() |> Enum.map(&cloak_config/1) end @spec generate_new_data_encryption_key() :: DataEncryptionKey.t() def generate_new_data_encryption_key do id = Ecto.UUID.generate() with( {:ok, response} <- key_encryption_key_arn() |> ExAws.KMS.generate_data_key_without_plaintext(encryption_context: %{"tag" => id}) |> ExAws.request(), ciphertext <- get_in(response, ["CiphertextBlob"]), {:ok, key} <- %DataEncryptionKey{ id: id, ciphertext: ciphertext } |> Database.Repo.insert() ) do key else err -> Logger.error("Failed creating new encryption key: #{err}") end end defp decrypt(encrypted_key_data, id) do with( {:ok, response} <- encrypted_key_data |> ExAws.KMS.decrypt(KeyId: key_encryption_key_arn(), EncryptionContext: %{"tag" => id}) |> ExAws.request() ) do get_in(response, ["Plaintext"]) end end defp cloak_config(%DataEncryptionKey{id: id, ciphertext: ciphertext}) do decoded_key = ciphertext |> decrypt(id) |> Base.decode64!() {String.to_atom(id), {Cloak.Ciphers.AES.GCM, tag: id, key: decoded_key}} end defp key_encryption_key_arn do Keyword.fetch!(env(), :key_encryption_key_arn) end defp max_data_encryption_key_age do Keyword.fetch!(env(), :max_data_encryption_key_age_in_seconds) end defp env do Application.get_env(:encryption, Encryption.KeySource.Kms, []) end end
We were able to configure the lifespan of any single data-encryption key
by configuring max_data_encryption_key_age_in_seconds
. In our
non-production deployed environments, we configured this value to 3600
or one hour. This enabled us to exercise key rotation frequently in our
non-production environment to verify it worked as intended. In our
production environment, key rotation did not happen as frequently.
We were quite pleased with the security and ergonomics of this solution. In particular, we were pleased that we didn’t need to manually intervene to do key rotation. That being said, this system was not run in production for a great deal of time so there may be dragons lurking.
Recommendations
All of the libraries that we used seemed to work quite well and provided
the building blocks we needed to implement this encryption schema.
However, in particular, I feel that cloak
and cloak_ecto
should do
more to educate their users.
cloak_ecto
does document how you can inject a data-encryption key
through the runtime environment. Doing this would allow you to implement
key-wrapping, but I would like to see a bit more guidance to users about
how to properly handle key data. The handling of the data-encryption key
is probably one of the most important parts of designing your
applications encryption scheme, and cloak_ecto
should give more
guidance.
Footnotes:
One can certainly implement key-wrapping using cloak_ecto
, as
this blog post details, and indeed one can implement key-wrapping
by following the patterns that cloak_ecto
documents and
recommends. I do think that cloak_ecto
documentation should be
slightly more robust and will make that case later on.