UP | HOME
dix

Software Curmudgeon

Mumblings and Grumblings About Software

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_ecto3 is an Elixir library that uses cloak4, 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_aws6 and ex_aws_kms7, 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:

7

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.