Building an Elixir Encryption Engine with Erlang's Crypto Module

Demystify encryption by taking a medium dive and building your very own encryption engine in Elixir. In this post, we'll build a light-weight Elixir library that leverages Erlang's :crypto to encrypt/decrypt sensitive data, and learn a little more about encryption along the way.

Overview

Our library will be able to do the following:

  • Generate a secret key that we will use to encrypt/decrypt strings of text
  • Use the secret key to encrypt and base64 encode a string
  • Use the secret key to decrypt and base64 decode a string

Let's get started!

Set Up

We'll generate a new project:

mix new encrypt  

Our project doesn't need many dependencies. We're going to use Erlang to do the encryption heavy lifting here, instead of relying on an existing encryption package. We will add Ex Doc though so that we can document our libary:

# mix.exs
defp deps do  
    [
      {:ex_doc, "~> 0.19", only: :dev, runtime: false}
    ]
  end

We'll install it with mix deps.get

Now we're ready to define our encryption module!

The Encryption Module

Generating the Secret Key

In order to encrypt/decrypt plain text, we need a secret key. Let's give our module the ability to generate such a key:

# lib/encrypt.ex

defmodule Encrypt do  
  @moduledoc """
  Documentation for Encrypt.
  """

  @doc """
  `generate_secret`
  Generates a random base64 encoded secret key.
  """
  def generate_secret do
    :crypto.strong_rand_bytes(16)
    |> :base64.encode
  end

Note that we've added some documentation using @doc.

Our secret generation code is pretty simple. First we use :crypto.strong_rand_bytes/1 to generates a binary composed of 16 random bytes. Then, we use Erlang's :base64.encode/1 to base 64 encode that binary. This gives us a string that we can store for later use in a file or environment variable.

Encrypt.generate_secret  
  # => "EC7+ItmG04KZzcS1Bg3o1g=="

Now that we have a secret key, we can use it to encrypt plaintext.

Encrypting Plaintext

We'll use :crypto.block_encrypt/4 to enact our encryption. The block_encrypt function needs the following arguments:

  • The mode of encryption we want to implement
  • The secret key we want to use for encryption
  • The initialization vector
  • The string we want to encrypt.

The block_encrypt function returns a tuple:

{ciphertext, ciphertag}

The ciphertext is the encrypted version of our original plaintext. The ciphertag is the "message authentication code", or MAC. This is a

...short piece of information used to authenticate a message—in other words, to confirm that the message came from the stated sender and has not been changed. The MAC value protects both a message's data integrity as well as its authenticity, by allowing verifiers to detect any changes to the message content.

We'll revisit block_encrypt/4 and take a closer look at the arguments it requires in a bit. First, let's define our encryption function to take in two arguments: the value we want to encrypt and the secret key with which we want to encrypt it.

defmodule Encrypt do  
  ...
  def encrypt(val, key) do
    # coming soon!
  end
end  

Now we're ready to craft the arguments we need to pass to :cryptol.block_encrypt/4

Specifing Encryption Mode

First off, we need to indicate the encryption mode. We'll use the Advanced Encryption Standard Galois Counter Mode which we can specify to block_encrypt as the atom: :aes_gcm

defmodule Encrypt do  
  ...
  def encrypt(val, key) do
    mode = :aes_gcm
  end
end  

Decoding the Secret Key

Next up, we need to pass the encryption key to this function call. However, we base-64 encoded our binary key in our generate_secret function. block_encrypt/4, however, needs the key in its binary form. So we need to base-64 decode the key:

defmodule Encrypt do  
  ...
  def encrypt(val, key) do
    mode       = :aes_gcm
    secret_key = :base64.decode(key)
  end
end  

Setting the Initialization Vector

The third argument we need to pass block_encrypt/4 is the initialization vector. The initialization vector is a random, fixed-size input that is used to enable block encryption to securely encrypt plaintext of any size.

Why do we need an initialization vector?

Block encryption will encrypt data of an arbitrary length by splitting that data into blocks, each matching the block cipher's size. Then, it will encrypt each block separately using the same secret key. This is NOT secure. Plaintext blocks of equal size get transformed into equal ciphertexts every time. In other words, every time you encrypt the same string, you end up with the same ciphertext. This gives any bad actors unacceptable insight into the nature of the encrypted data.

Using an initialization vector solves this problem by randomizing the input data.

Every time we perform encryption, a different random set of bytes is mixed with the first block of input data. Then the ciphertext from the first block's encryption is added to the second block, the ciphertext from the second block is added to the third and so on. This provides semantic security. Every time we encrypt the same string, we end up with different ciphertext, thereby ensuring that an attacker can't make any conclusions about the original plaintext by observing the encrypted ciphertext.

We'll generate an initialization vector using the same technique we used to kick off the generation of our secret key, the :crypto.strong_rand_bytes/1 function.

defmodule Encrypt do  
  ...
  def encrypt(val, key) do
    mode       = :aes_gcm
    secret_key = :base64.decode(key)
    iv         = :crypto.strong_rand_bytes(16)
  end
end  

Specifying the Encryption Value Tuple

We need to pass the value to be encrypted into block_encrypt/4 via a tuple:

{AAD, Plaintext, TagLength}

This will tell block_encrypt/4 to take our plaintext, and using the Authenticated Encryption with Associated Data mode (AEAD), encrypt it with the method specified in the first argument, :aes_gcm. We'll specify an AEAD mode of "AES256GCM" which communicates the encryption mode in three parts:

  • AES: Advanced Encryption Standard.
  • 256: "256 Bit Key"
  • GCM: "Galois Counter Mode"

Let's put it all together:

defmodule Encrypt do  
  @aad "AES256GCM"
  ...
  def encrypt(val, key) do
    mode       = :aes_gcm
    secret_key = :base64.decode(key)
    iv         = :crypto.strong_rand_bytes(16)
    {ciphertext, ciphertag} = :crypto.block_encrypt(mode, secret_key, {@aad, to_string(val), 16})
  end
end  

Note: We set the AEAD as a module attribute since we will reuse it when we perform our decryption.

Returning the Encrypted Data

In order to be able to decrypt our encrypted data, we need to use not just the ciphertext, but the ciphertag and initialization vector. So, our function will return a concatenation of these three pieces of data:

defmodule Encrypt do  
  @aad "AES256GCM"
  ...
  def encrypt(val, key) do
    mode       = :aes_gcm
    secret_key = :base64.decode(key)
    iv         = :crypto.strong_rand_bytes(16)
    {ciphertext, ciphertag} = :crypto.block_encrypt(mode, secret_key, {@aad, to_string(val), 16})
    iv <> ciphertag <> ciphertext
  end
end  

Now we have something that works like this:

secret = Encrypt.execute_action([action: "generate_secret"])  
=> "+fmYuFICS1a5ZVxesmRrpQ=="

Encrypt.encrypt("This is so cool", secret)  
=> <<248, 28, 213, 4, 12, 55, 173, 193, 119, 45, 103, 255, 207, 249, 240, 198, 113,
  221, 79, 255, 107, 234, 150, 115, 0, 120, 174, 37, 24, 140, 188, 25, 123, 189,
  210, 87, 37, 11, 52, 29, 150, 120, 197, 99, 209, 242, 165>>

We can see that this returns a binary. To make it easier to store our encrypted data, we'll take the next step of transforming this binary into a base-64 encoded string.

defmodule Encrypt do  
  @aad "AES256GCM"
  ...
  def encrypt(val, key) do
    mode       = :aes_gcm
    secret_key = :base64.decode(key)
    iv         = :crypto.strong_rand_bytes(16)
    {ciphertext, ciphertag} = :crypto.block_encrypt(mode, secret_key, {@aad, to_string(val), 16})
    iv <> ciphertag <> ciphertext
    |> :base64.encode
  end
end  

Now we end up with the return value:

ciphertext = Encrypt.encrypt("This is so cool", secret)  
=> "BVb85x/NaL4lbZr2ZD8E3Q1SsfxLo9mAOiH+GGXCH6YH7QGHf52AfzsAOc46enI="

Decrypting Ciphertext

Now we're ready to build our decryption function. For this, we'll rely on :crypto.block_decrypt/4

First, let's define our decrypt function to take in an argument of the ciphertext and the secret key that was used to encrypt it.

defmodule Encrypt do  
  def decrypt(ciphertext, key) do do
    # coming soon!
  end
end  

The four arguments we need to give block_decrypt are:

  • The mode of encryption
  • The secret key
  • The initialization vector
  • The ciphertext to decrypt

We already know how construct our first two arguments––just exactly as we did for our block_encrypt function call:

defmodule Encrypt do  
  def decrypt(ciphertext, key) do do
    mode = :aes_gcm
    secret_key = :base64.decode(key)
  end
end  

But where do we get our initialization vector? We need to grab the same initialization vector that we used to enact our encryption. Recall that our encrypt function returned the base64 encoded concatenation of our initialization vector, our ciphertext and our ciphtertag in the format:

iv <> ciphertext <> ciphertag  

We we'll use pattern matching to extract these components from the encrypted and encoded value we passed into decrypt:

defmodule Encrypt do  
  def decrypt(ciphertext, key) do do
    mode = :aes_gcm
    secret_key = :base64.decode(key)
    ciphertext = :base64.decode(ciphertext)
    <<iv::binary-16, tag::binary-16, ciphertext::binary>> = ciphertext
  end
end  

This way, we expose the initialization vector, iv, the ciphertext that represents the encrypted plaintext, ciphertext, and the ciphertag, tag––all of which we need in order to perform our decryption.

Now that we have all of these components available to us, we're ready to invoke block_decrypt:

defmodule Encrypt do  
  def decrypt(ciphertext, key) do do
    mode = :aes_gcm
    secret_key = :base64.decode(key)
    ciphertext = :base64.decode(ciphertext)
    <<iv::binary-16, tag::binary-16, ciphertext::binary>> = ciphertext
    :crypto.block_decrypt(mode, secret_key, iv, {@aad, ciphertext, tag})
  end
end  

Note that the fourth argument is a tuple that specifies the encryption mode, the cipher text and the tag.

We can use our function like this to return the decrypted plaintext:

Encrypt.decrypt(ciphertext, secret)  
=> "This is so cool"

Yes, it is so cool.

Next Steps

At The Flatiron School, we built our encryption engine into a command line executable using escript and released it as a Hex package. You can check out the code for this project here and install the package:

mix escript.install hex encrypt  

Conclusion

Building this library provided me with some insight into the encryption process, and it made me really appreciate Elixir's ability to use any native Erlang functions. It made it easy to produce such a lightweight and extensible library, and Erlang's documentation helped me to finally peek under the hood and understand a bit about how encryption works. I hope it did the same for you. Happy coding!

subscribe and never miss a post!

Blog Logo

Sophie DeBenedetto

comments powered by Disqus
comments powered by Disqus