Hey! I recorded two video courses!

My two video courses are called: Build an MVP with Elixir and Building Forms with Phoenix LiveView
If you like this article, you will also like the courses! Check them out here!

Hey folks, welcome back to another blog post! :)

I’m sorry for the delay since my last blog post, but I’ve been busy with creating my video course Build an MVP with Elixir and speaking at ElixirConf EU in Lisbon.

But now I’m back and will post new blog posts every two weeks again. So, before we dive into today’s blog post, first the ceremonial dad joke:

I don't have a dad body.

I have a father figure.

Anyways! This post is about how to encrypt and upload sensitive user files to Amazon’s S3, so let’s dive right in!

🔗 The Problem

When you ask users to upload their files to your service, you will probably store them in a storage service like S3. S3 and others are cheaper alternatives to storing the files in your database. They also make it easier to link and show the files in e.g. your UI.

However, if your users upload sensitive files, it’s paramount to store them in a way that no other than yourself and the user can access them. This is where file encryption comes in. So, whenever you save a file in e.g. S3, you only store an encrypted version of the file. This way, neither Amazon nor any attacker who gains access to your S3 bucket can read the files. Sounds great, no?

But how do set up the encryption, decryption, upload, and download functionality in Elixir and Phoenix LiveView? This blog post answers how :)

🔗 The Solution

The following demo project is quite extensive, but here’s what it does in a nutshell:

🔗 For uploading files

The demo project uses a LiveView to allow users to upload their passport as PDF, PNG, or JPG file. After an upload completes, it encrypts the file using the AES256 algorithm with a key that’s unique for each user. It then uploads the encrypted file to S3 using the ExAws library.

🔗 For downloading files

In the same LiveView, the demo project lists all files of the current user. When a user clicks on a file, the LiveView shows either an img or embed HTML tag based on whether the file is an image or a PDF. The new HTML tag fetches the image from a Phoenix Controller, which returns the decrypted file after downloading it from S3.

But that’s enough abstract speak. Here’s how it will look like:

Enough prelude! Let’s write some code!

🔗 The Config

We will use the ExAws library to upload and download the files to S3. Therefore, we need to add the following dependencies to our project:

# mix.exs
defp deps do
  [
    {:ex_aws, "~> 2.0"},
    {:ex_aws_s3, "~> 2.0"},
    {:hackney, "~> 1.9"},
    {:sweet_xml, "~> 0.6"}
  ]
end

Next, we need to add the runtime configuration for our S3 Bucket. Here’s how:

# runtime.exs
config :ex_aws,
  access_key_id: [System.get_env("AWS_ACCESS_KEY_ID"), :instance_role],
  secret_access_key: [System.get_env("AWS_SECRET_ACCESS_KEY"), :instance_role]

config :demo,
  aws_file_bucket: System.get_env("AWS_FILE_BUCKET"), # <- The name of your bucket
  aws_file_bucket_region: System.get_env("AWS_FILE_BUCKET_REGION") # <- The region of your bucket, e.g. "eu-west-3"

And that’s the configuration! If you start your server with iex -S mix phx.server, you should be able to make calls to your S3 bucket using ExAws. You can test this by running:

bucket = Application.get_env(:demo, :aws_file_bucket)
region = Application.get_env(:demo, :aws_file_bucket_region)

ExAws.S3.list_objects(bucket) |> ExAws.request(region: region)

🔗 Why Unique Keys

As mentioned before, we want to encrypt every file with an encryption key that is unique per user. You could also encrypt all files with the same encryption key, but then you put all eggs in one basket. If an attacker gains access to your encryption key, they can decrypt and view all user files. That’s not ideal.

If we decrypt every file with an encryption key that is unique to a user, an attacker would need to extract all these encryption keys instead of only a single key, which diversifies the risk a bit. There are two other benefits though:

First, we store the encryption keys in our database and never on our servers as e.g. environment variables. So, even if an attacker gains access to our servers, they won’t be able to steal these keys since keys are kept only temporarily in the application memory.

Second, if we introduce a bug in our application that allows users to view the files of other users, they won’t be able to decrypt them if we always use the current_user for decryption. If we’d encrypt all files with the same key, we wouldn’t have this security check because we’d decrypt the file with the same key, regardless of who’s the currently logged-in user. But if we use the encryption key of the current user for decrypting the file, the result would be gibberish or worst-case, throw an error.

The only caveat of this implementation is that if we change the owner of a file, we need to download and decrypt the file with the old owner’s encryption key, and then encrypt and upload the file again with key of the new owner. That’s a bit more hassle than simply updating the file.owner_id field in the database, but it’s not that complicated either.

So, given all these benefits of having a unique key per user, let’s start implementing it!

🔗 The User Schema

First, let’s look at the schemas. This is the User-schema:

defmodule Demo.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :encryption_key, :string
  end

  @doc false
  def changeset(file_user, attrs) do
    file_user
    |> cast(attrs, [:encryption_key])
    |> add_encryption_key()
    |> validate_required([:encryption_key])
  end

  defp add_encryption_key(changeset) do
    case get_field(changeset, :encryption_key) do
      nil ->
        key = GenerateSecureString.call(32)
        put_change(changeset, :encryption_key, key)

      _ ->
        changeset
    end
  end
end

In our simplified User-schema, we only store one field: the encryption_key. We set this key when we create the user using the add_encryption_key/1 helper. If we don’t set the encryption key while creating the user, this will generate a new random key of 32 bytes using the GenerateSecureString-helper module. Let’s have a look at that next.

🔗 Generate Secrets

The GenerateSecureString module only has a single function: call/1. It uses the erlang :crypto-library to generate random bytes of a given number and encodes them to Base64. As a rule of thumb, you never want to store bytes directly into the database, but always want to encode them first using e.g. Base64. If not, you might lose some bytes during the transfer.

defmodule Demo.GenerateSecureString do
  def call(size) do
    size
    |> :crypto.strong_rand_bytes()
    |> Base.encode64()
  end
end

🔗 The File Schema

For every uploaded file, we create a File-schema in our database. This is it:

defmodule Demo.Files.File do
  use Ecto.Schema
  import Ecto.Changeset

  schema "files" do
    field(:slug, Ecto.UUID, autogenerate: true)
    field(:encryption_iv, :string)
    field(:resource_path, :string)
    field(:filename, :string)
    field(:content_type, Ecto.Enum, values: [:pdf, :jpeg, :png])

    belongs_to(:owner, Demo.Accounts.User)

    timestamps()
  end

  def changeset(file, attrs) do
    file
    |> cast(attrs, [:owner_id, :encryption_iv, :filename, :resource_path, :content_type])
    |> validate_required([:owner_id, :encryption_iv, :filename, :resource_path, :content_type])
  end
end

In the File-schema, we generate a unique slug that allows us to fetch the file through our controller using an HTTP request like GET /files/#{file.slug}. We also store the encryption salt or encryption_iv. Next, we store the resource_path. That’s the path in our S3 bucket to the file, e.g. /files/another-random-slug. We need this path for downloading the file using ExAws.S3.get_object/3. We also store the filename and content_type. This makes it easier to display the file in our UI as either image or embedded PDF. Lastly, we store the relationship of the file to the User-schema as owner_id here. This allows us to use the encryption key of the user for decrypting the file.

And that’s it regarding the schemas! Next, we’ll look at the LiveView and the encryption and decryption services.

🔗 The LiveView

Our LiveView does a lot in this demo project. It allows the user to upload a passport file as PDF, PNG, or JPG. It displays all files of the user, and it embeds either an <img> or an <embed> element for displaying the files. Have a look at the complete code below and then we’ll discuss it afterward:

defmodule DemoWeb.UploadLive do
  use DemoWeb, :live_view

  alias Demo.Files.EncryptAndUploadFile

  @impl true
  def render(assigns) do
    ~H"""
    <div>
      <h1>Upload</h1>
      <form id="upload-form" phx-submit="save" phx-change="validate">
        <.live_file_input upload={@uploads.passport} />
        <button type="submit" phx-disable-with="Uploading...">Upload Passport</button>
      </form>

      <ul>
        <li :for={file <- @files} id={"file-#{file.id}"} phx-click="show" phx-value-file_id={file.id}>
          <%= file.filename %>
        </li>
      </ul>

      <%= if @current_file && @current_file.content_type in [:jpeg, :png] do %>
        <img src={~p"/files/#{@current_file.slug}"} />
      <% end %>

      <%= if @current_file && @current_file.content_type in [:pdf] do %>
        <embed src={~p"/files/#{@current_file.slug}"} type="application/pdf" width="400" height="800" />
      <% end %>
    </div>
    """
  end

  on_mount({DemoWeb.UserAuth, :mount_current_user})

  def mount(_params, _session, socket) do
    socket =
      socket
      |> assign_files()
      |> assign(:current_file, nil)
      |> allow_upload(:passport, accept: ~w(.pdf .jpg .jpeg .png))

    {:ok, socket}
  end

  def handle_event("validate", _params, socket), do: {:noreply, socket}

  def handle_event("save", _params, socket) do
    current_user = socket.assigns.current_user

    result =
      consume_uploaded_entries(socket, :passport, fn %{path: path}, entry ->
        content = File.read!(path)
        filename = entry.client_name
        content_type = parse_content_type(entry)
        result = EncryptAndUploadFile.call(current_user, content, filename, content_type)

        case result do
          {:ok, _file} -> {:ok, :ok}
          _error -> {:postpone, {:error, error}}
        end
      end)

    case result do
      {:error, _error} -> {:noreply, put_flash(socket, :error, "Upload failed. Please try again.")}
      :ok -> {:noreply, socket |> assign_files() |> put_flash(:info, "Upload successful!")}
    end
  end

  defp parse_content_type(entry) do
    case entry.client_type do
      "image/png" -> :png
      "image/jpeg" -> :jpeg
      "application/pdf" -> :pdf
    end
  end

  defp assign_files(socket) do
    files = Files.list_files_for_user(socket.assigns.current_user)
    assign(socket, :files, files)
  end
end

Let’s go through this code from top to bottom. First, we create our heex template that shows a small form that accepts a live_file_upload input called :passport. Next, it lists all files of the currently logged-in user and if the user clicks on one of these files, it sends show event to the LiveView. Lastly, it embeds either an image or an embed element, which receives the URL path to the currently selected file through the src attribute.

You might wonder why we don’t embed the file directly from the LiveView by assigning its data Base64 encoded to an image HTML element. Although this approach works for images, it doesn’t work (well) for PDFs. It’s easier to let the HTML element make the HTTP request and render the returned value.

After the render/1 function, we have the usual mount/3 callback followed by the handle_event("validate", _params, socket) function. These functions are necessary for displaying the form and the files, but not that interesting. Let’s move on.

The juicy stuff begins in the event handler of the save event. Here, we consume the uploaded file and pass it on to the EncryptAndUploadFile service. In the consume_uploaded_entries function, we first read the file content from the temporary file that Phoenix created for us. We parse the content type from the defined types like application/pdf to something that makes it easier to work with in Elixir like the atom :pdf. You don’t necessarily need to do this, but it makes it easier to check the type of a file in your UI.

We’ll get to the EncryptAndUploadFile service later, but based on its result, we either return :ok or {:error, error} from the consume function and put a success or error flash on our socket which will show a notification toast in our UI.

Okay, this LiveView is quite complex, so I’d advise you to copy&paste it into your own application and to play around with it.

Now, let’s move on the controller that downloads, decrypts, and returns the files when the <img> or <embed> elements request it.

🔗 The Controller

defmodule DemoWeb.FileController do
  use DemoWeb, :controller

  alias Demo.Files
  alias Demo.Files.DownloadEncryptedFile

  def show(conn, %{"slug" => slug}) do
    current_user = conn.assigns.current_user
    file = Files.get_file_by_slug!(slug)

    {:ok, raw_content} = DownloadEncryptedFile.call(current_user, file)

    conn
    |> put_resp_header("content-type", convert_content_type(file))
    |> send_resp(200, raw_content)
  end

  defp convert_content_type(file) do
    case file.content_type do
      :pdf -> "application/pdf"
      :jpeg -> "image/jpeg"
      :png -> "image/png"
    end
  end
end

The controller isn’t horribly complicated, but simply fetches a file from the database by the given slug, hands over the file to the DownloadEncryptedFile service, which we’ll get into later, and returns the decrypted file together with its content type as response header.

Moving on the last piece of the (web) puzzle, the Routes.

🔗 The Router

# router.ex
  live("/upload", UploadLive)
  get("/files/:slug", FileController, :show)

Add these two routes to your router.ex and you should be able to upload and view your files.

Now, let’s move on to the really interesting bits: The encryption and decryption services.

🔗 Encrypt and Upload Files

Our EncryptAndUploadFile service takes care of creating a file schema, encrypting its contents, and uploading them to S3. Have a look at the code first. We’ll discuss it below:

defmodule Demo.Files.EncryptAndUploadFile do
  alias Demo.Files
  alias Demo.Files.GenerateSecureString
  alias Demo.Repo

  @iv_size 16

  def call(current_user, byte_content, filename, content_type) do
    key = current_user.encryption_key
    iv = GenerateSecureString.call(@iv_size)
    resource_path = "/files/#{Ecto.UUID.generate()}"

    Repo.transact(fn ->
      with {:ok, file} <- create_file(current_user, resource_path, filename, content_type, iv),
           encrypted_content <- encrypt(byte_content, key, iv),
           {:ok, _result} <- upload(file, encrypted_content) do
        {:ok, file}
      end
    end)
  end

  defp create_file(current_user, resource_path, filename, content_type, iv) do
    Files.create_file(%{
      owner_id: current_user.id,
      resource_path: resource_path,
      filename: filename,
      content_type: content_type,
      encryption_iv: iv
    })
  end

  defp encrypt(content, key, iv) do
    key = Base.decode64!(key)
    iv = Base.decode64!(iv)
    content = content |> Base.encode64() |> pad()

    :aes_256_cbc
    |> :crypto.crypto_one_time(key, iv, content, true)
    |> Base.encode64()
  end

  defp upload(file, encrypted_content) do
    get_bucket()
    |> ExAws.S3.put_object(file.resource_path, encrypted_content)
    |> ExAws.request(region: get_region())
  end

  defp pad(data, block_size \\ 16) do
    to_add = block_size - rem(byte_size(data), block_size)
    data <> String.duplicate(<<to_add>>, to_add)
  end

  defp get_bucket(), do: Application.get_env(:demo, :aws_file_bucket)
  defp get_region(), do: Application.get_env(:demo, :aws_file_bucket_region)
end

Now, let’s go through the code from top to bottom. In the call/4 function, we first assemble all necessary fields to create a File-schema. We generate a new encryption salt and a random resource_path. Note that the slug used in the resource_path isn’t the same as the file.slug. This is a choice that I’ve made here deliberately. Imagine that an attacker gains access to your S3 bucket filenames. They might use the slug in the filename to try to fetch the files from the GET /files/{file_slug} route. If we use the same slug for the resource path and the file.slug field, they might succeed. But, if we have two different slugs, their requests to this route will return 404. So, yet another layer of security.

Now, with these variables, we first create a new File-record, encrypt its contents, and upload them to S3. The interesting part here is the encryption, so let’s look at that in more detail.

The encrypt/3 function first decodes the encryption key and salt from their Base64 encoding. Remember that we store these fields always as base64 to prevent byte loss. That’s why we also encode the raw byte content to base64 first so that S3 doesn’t lose any bytes either. After that, we add some padding to the encoded content. The padding is necessary, because our encryption algorithm needs an input that is divisible by its block size of 16 bytes. If we have content that isn’t divisible by 16, our encryption and decryption might fail and our data is lost.

In our case, we always add padding. Even if the content is exactly a multiple of 16, we still add another block of padding of 16 bytes. Otherwise, it would be hard to figure out whether our last byte is the amount of padding we added, or belongs to the actual data.

After we padded the data, we encrypt it using the :crypto.crypto_one_time/5 function, which is the replacement for the old block_encrypt/3 function. We encode the result to Base64 again to store it on S3. The true parameter means that we’re encrypting and not decrypting (which would be false). The rest is pretty straightforward. We use ExAws.S3 to upload the file to its resource_path and finally return {:ok, file}.

Now, you might have noticed the unusual Repo.transact/2 function, which wraps the creation of the File-record and the uploading of the file to S3. This function is a handy wrapper which Tom Konidas wrote about here. It’s a neat helper function around Repo.transction/1 that handles the return for us. It looks like this:

🔗 Repo.transact/2

# Taken from https://tomkonidas.com/repo-transact/
defmodule Demo.Repo
  def transact(fun, opts \\ []) do
    transaction(
      fn ->
        case fun.() do
          {:ok, value} -> value
          :ok -> :transaction_commited
          {:error, reason} -> rollback(reason)
          :error -> rollback(:transaction_rollback_error)
        end
      end,
      opts
    )
  end
end

And that’s it! Now you know how to encrypt and upload files to S3. Now, let’s look at the downloading and decryption part.

🔗 Download Files

Our DownloadEncryptedFile service downloads the file using its resource_path and decrypts it. Here’s how:

defmodule Demo.Files.DownloadEncryptedFile do
  def call(current_user, file) do
    with {:ok, encrypted_content} <- download(file),
         {:ok, content} <- decrypt(file, encrypted_content, current_user) do
      {:ok, content}
    end
  end

  defp download(file) do
    get_bucket()
    |> ExAws.S3.get_object(file.resource_path)
    |> ExAws.request(region: get_region())
    |> case do
      {:ok, %{body: body}} -> {:ok, body}
      error -> error
    end
  end

  defp decrypt(file, encrypted_content, current_user) do
    key = Base.decode64!(current_user.encryption_key)
    iv = Base.decode64!(file.encryption_iv)
    encrypted_content = Base.decode64!(encrypted_content)

    :aes_256_cbc
    |> :crypto.crypto_one_time(key, iv, encrypted_content, false)
    |> unpad()
    |> Base.decode64()
  end

  def unpad(data) do
    to_remove = :binary.last(data)
    :binary.part(data, 0, byte_size(data) - to_remove)
  end

  defp get_bucket(), do: Application.get_env(:demo, :aws_file_bucket)
  defp get_region(), do: Application.get_env(:demo, :aws_file_bucket_region)
end

The service first downloads the file from its file.resource_path and then decrypts it. The decryption looks very similar to the encryption, but in reverse and with a false-flag in the :crypto.crypto_one_time/5 function for decryption. If you look at the unpad/1 function, you see how we remove the last bytes that we used for padding during the encryption.

The service returns raw bytes, which we can then return from our Phoenix Controller and show in our <img> or <embed> elements.

🔗 Demo Time!

And that’s it! If you put all these pieces together, you’ll get a working demo for encrypting and uploading, and downloading and decrypting of user files to S3. Here’s a little video to demonstrate that it works:

A gif that shows the full flow of uploading and downloading an encrypted file to and from S3

🔗 Conclusion

And that’s it! I hope you enjoyed this article! If you have questions or comments, let’s discuss them on Twitter. Follow me on Twitter or subscribe to my newsletter below if you want to get notified when I publish the next blog post. Until the next time! Cheerio 👋

Stay updated about my work