Cover image

Database Encryption with the Phoenix Auth Generators

Written by Gus Workman
Published on 2023-02-13

In today’s digital age, users expect a high level of privacy when it comes to their personal information. It’s no secret that data breaches and leaks are becoming increasingly common, which makes it more important than ever to protect user data from unauthorized access. One effective way to do this is by encrypting the sensitive data stored in your database.

By encrypting data with a library like Cloak, you can prevent a data leak from exposing sensitive user information, and ensure that your users’ personal data remains private and secure.

In this blog post, we’ll dive into how to integrate the Cloak database encryption library into a Phoenix application, including integration with the standard Phoenix authentication generators.

Getting Started

I’m currently using Elixir 1.14.2 on OTP 25. I have the latest Phoenix generator installed as of the time of writing, which is 1.7.0-rc.2. To begin, I’m going to generate a new Phoenix app and then run the phx.gen.auth command as shown below. I’m using SQLite3 here in order to reduce local dependencies - but the default PostgreSQL will work just the same!

mix phx.new secure_app --database sqlite3
mix phx.gen.auth Accounts User users

After this step, you can check out your default home page at http://localhost:4000 and notice the register and log in links at the top left corner. Feel free to try registration, email verification, password reset, etc to get a feel for the experience that the authentication generator creates. As a reminder, you can also simulate verifying your email by checking the inbox at http://localhost:4000/dev/mailbox.

If we take a look inside the database, we can take a peek at the tables and the data it contains:

➜ sqlite3 secure_app_dev.db

SQLite version 3.37.0 2021-12-09 01:34:53
Enter ".help" for usage hints.

sqlite> .tables
schema_migrations  users              users_tokens

sqlite> select * from users;
1|me@example.com|$2b$12$e/oTc3/PLYR60MRCN70lW.k8yqPYqgF0JZ0azhT.QpM3WuWeKgPLS|2023-02-12T02:23:21|2023-02-12T02:22:21|2023-02-12T02:23:21

This all looks pretty standard. We have a users table which stores an ID, email, hashed password and some timestamps. You can also see that this is the case by checking out the migration for the authentication tables:

defmodule SecureApp.Repo.Migrations.CreateUsersAuthTables do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :email, :string, null: false, collate: :nocase
      add :hashed_password, :string, null: false
      add :confirmed_at, :naive_datetime
      timestamps()
    end

    create unique_index(:users, [:email])

    create table(:users_tokens) do
      add :user_id, references(:users, on_delete: :delete_all), null: false
      add :token, :binary, null: false, size: 32
      add :context, :string, null: false
      add :sent_to, :string
      timestamps(updated_at: false)
    end

    create index(:users_tokens, [:user_id])
    create unique_index(:users_tokens, [:context, :token])
  end
end

We want to ensure our users’ privacy. In this case, we are going to consider the user email to be a sensitive field. If our server gets compromised, we don’t want the attacker to be able to read our database full of user emails. So how do we achieve that? Encrypt the email column!

Setup and Configuration of Cloak

To encrypt our database columns, we are going to use the excellent cloak and cloak_ecto libraries.

Let’s add them to our dependencies in mix.exs:

  defp deps do
    [
      # ...
      {:cloak, "~> 1.1.2"},
      {:cloak_ecto, "~> 1.2.0"}
    ]
  end

Next, we need to define and configure a vault for Cloak. The definition of this module is simple, since it mostly invokes the use of the Cloak.Vault macro, but behind the scenes it is the module performs encryption and decrytion of your data as it moves between your app and the database. The definition of the vault is as below - be sure to change the module name and the opt_app field to reflect your app’s name!

defmodule SecureApp.Vault do
  use Cloak.Vault, otp_app: :secure_app
end

Next, we need to supply this module with configuration options in dev.exs. There are a few keys of note. First, you can change the JSON library to your library of choice by setting the json_library key. The default is Jason. The second configuration option is the ciphers value. You could configure multiple cipher algorithms here, but we are only going to use the Cloak.Ciphers.AES.GCM implementation. For this cipher, we can also supply a keyword list of options with the tag, key and iv_length keys set. The tag is an identify to help the library detect which key data was encrypted with. The key is a secret value that is used to encrypt the data. Keep the key value safe, as leaking the key will allow anyone to decrypt your data. Finally, the iv_length is a parameter of the encyption algorith - the standard value recommended by the library author is 12.

config :secure_app, SecureApp.Vault,
  json_library: Jason,
  ciphers: [
    default: {Cloak.Ciphers.AES.GCM,
    tag: "AES.GCM.V1",
    key: Base.decode64!("your-key-here"),
    iv_length: 12}
  ]

You can see above that the key parameter is expecting a base64 encoded key value. Unfortunately, "your-key-here" is neither a valid base64 string nor would it be a secure key even if it was. So, let’s generate a new key. We can do so within iex:

iex(1)> 32 |> :crypto.strong_rand_bytes() |> Base.encode64
"0xO0pwiFpvutsAvmASgYJR9q.......54SY9r+M="

Fortunately, the above snippet of code is a very easy way to generate a strong key. I redacted some of the middle characters of the key - please be sure to generate a key for yourself, do not copy the one shown here!

Now that we have a key, just paste the string value into the config options for the SecureApp.Vault module.

The last step for the Cloak module is to start it in the supervision tree. In application.ex, I’ve added the following:

def start(_type, _args) do
  children = [
    # ...
    # Start the Vault
    SecureApp.Vault,
    # ...
  ]

  # ...
end

Now the Cloak Vault itself is set up - but we have one last step before completing the setup and configuration. That is to define our encrypted data types, similar to how we created the Vault module. There are a number of them available, corresponding to numerical data types, dates and times, as well as lists. Check out the Cloak Ecto docs to learn more about the supported data types. For this purpose, we want to encrypt our binaries (strings), so we will use the Cloak.Ecto.Binary module.

defmodule SecureApp.Encrypted.Binary do
  use Cloak.Ecto.Binary, vault: SecureApp.Vault
end

In addition, we want to be able to securely query the email field - for purposes such as log in a user from email and password, and checking that the email provided during registration is unique. To do this, we also need a hash field. Some common hashes are for example SHA256, HMAC, PBKDF2, etc. Although PBKDF2 is stronger, we are going to use HMAC in this tutorial because it has no extra dependencies. To use it in the project, add one more module as shown below:

defmodule SecureApp.Hashed.HMAC do
  use Cloak.Ecto.HMAC, otp_app: :secure_app
end

And this module has some simple configuration options, I will add them here below. In this application, I will use a secret key generated the same way as above. The key helps prevent attackers from using a rainbow table to easily recover the original contents of the hashed field.

config :secure_app, SecureApp.Hashed.HMAC,
  algorithm: :sha512,
  secret: "NGbyAd0gQgiIBfZLHiws.......XGwWSuyWp70w="

And that’s almost all there is to the configuration - now we can start encrypting our database fields!

Encrypting Authentication Tables with Cloak Ecto

The next part of the process is to implement the encryption on the sensitive columns of our authentication tables - in this case the email column of the users table.

To start, we need to re-define the data type in the migration and the schema for the users. In the migration, I will set the data type to :binary, because the encrypted data is stored in a raw binary format. For the schema, I will change the email field to the SecureApp.Encrypted.Binary data type that we defined above. Simple!

In the migration, change the email field to the following two fields:

create table(:users) do
  add :email, :binary, null: false
  add :email_hashed, :binary, null: false
  # ...
end

And in the user schema:

schema "users" do
  field :email, SecureApp.Encrypted.Binary
  field :email_hashed, SecureApp.Hashed.HMAC
  # ...
end

Next, we need to make sure that the email_hashed field is set when our changesets are invoked. To do so, let’s create the function, maybe_put_hashed_email/1, which will copy the email value to the email_hashed value if it is not null.

defp maybe_put_hashed_email(changeset) do
  email = get_field(changeset, :email)

  if email do
    put_change(changeset, :email_hashed, email |> String.downcase())
  else
    changeset
  end
end

Now, we want to make sure that this function is called in the validate_email/2 pipeline:

defp validate_email(changeset, opts) do
  changeset
  |> validate_required([:email])
  |> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
  |> validate_length(:email, max: 160)
  |> maybe_put_hashed_email() # <- added this line here
  |> maybe_validate_unique_email(opts)
end

And finally, we want to ensure that the maybe_validate_unique_email/2 function is using the email_hashed field instead of the email field, since the encrypted binary is not searchable:

defp maybe_validate_unique_email(changeset, opts) do
  if Keyword.get(opts, :validate_email, true) do
    changeset
    |> unsafe_validate_unique(:email_hashed, SecureApp.Repo)
    |> unique_constraint(:email_hashed)
  else
    changeset
  end
end

Okay so at this point, registering for a new account should work. However, I get an error flash message, Invalid email or password, after testing it out. Oddly enough, the user does exist in the database, however.

The reason for the error is that the LiveView event handlers are creating the user record in the database. After that succeeds, it submits the form to the login route, so the user can be logged in immediately after registration. The code for the login routes can be found in user_session_controller.ex.

By inspecting the controller code, we can see that the error message above is shown to the user when the call to Accounts.get_user_by_email_and_password/2 fails. By looking at it, we can notice that the query is trying to lookup the user by the email field. However, this is not searchable since it is encrypted - instead we need to change it to email_hashed, as below:

def get_user_by_email_and_password(email, password)
    when is_binary(email) and is_binary(password) do
  user = Repo.get_by(User, email_hashed: email)
  if User.valid_password?(user, password), do: user
end

We should also update the get_user_by_email/1 function just above it to use the same email_hashed field:

def get_user_by_email(email) when is_binary(email) do
  Repo.get_by(User, email_hashed: email)
end

Great! So if you go back through the registration and login process, it works without issue! We’re done, right? … Not quite, unfortunately.

Fixing the Tests

The phx.gen.auth also generates valid tests. Since we just changed implementation details of the authentication system, all of the public APIs should still be the same. So we can run the tests to ensure we haven’t broken anything:

mix test

...

Finished in 1.1 seconds (0.8s async, 0.3s sync)
128 tests, 107 failures

😬

The good news is that most of the problem is related to the configuration - if you remember, we only set up the Cloak key and cipher in dev.exs. So, I will copy the config over to test.exs, and generate new keys for the AES and HMAC configuration. While we’re at it, we might as well configure the application for production as well.

For a production deployment, we typically want to set the encryption keys as secrets or environment variables, depending on your hosting solution. You especially do not want to commit the secrets as a part of the code base. To read the configuration from the environment at application start, I will add the following configuration code in runtime.exs, in the :prod block:

if config_env() == :prod do
  # ...

  # read the encryption key parameters from environment
  encryption_key =
    System.get_env("ENCRYPTION_KEY") ||
      raise """
      environment variable ENCRYPTION_KEY is missing.
      You can generate one by running: 32 |> :crypto.strong_rand_bytes() |> Base.encode64
      in IEX
      """

  hmac_secret =
    System.get_env("HMAC_SECRET") ||
      raise """
      environment variable HMAC_SECRET is missing.
      You can generate one by running: 32 |> :crypto.strong_rand_bytes() |> Base.encode64
      in IEX
      """

  # configure the vault encryption method and parameters
  config :secure_app, SecureApp.Vault,
    json_library: Jason,
    ciphers: [
      default:
        {Cloak.Ciphers.AES.GCM,
         tag: "AES.GCM.V1", key: Base.decode64!(encryption_key), iv_length: 12}
    ]

  config :secure_app, SecureApp.Hashed.HMAC,
    algorithm: :sha512,
    secret: hmac_secret

  # ...
end

Now all we need to do is generate some new encryption keys and set the ENCRYPTION_KEY and HMAC_SECRET in our server’s secrets or environment variables. Reminder: do not store the plaintext value of these secrets within your repository!

After setting the proper test configuration, we can run mix test again:

mix test

...

Finished in 1.7 seconds (0.6s async, 1.1s sync)
128 tests, 12 failures

That’s much better at least! Still some work to get the tests working though. We will cover the rest in Part 2.

Stay tuned!