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!