Seeds grow in the underground

Elixir - Signing for Cloudfront resources

2019-12-20

This covers how to create Signed URL Custom Policies with Cloudfront in Elixir.

If you are working with Elixir and AWS you are probably familiar with ExAws. If you are unfamiliar, let's cover some quick ground:

While exploring the ExAws library for a project I checked things like "can it presign URLs?", and sure enough ExAws.S3 has presign. "So I assume it can create a signed resource thing for Cloudfront, right?", well, not quite. So I was thinking that maybe no-one would have needed it yet. But that seemed a bit odd. And then I checked around a bit. One reason could be because Cloudfront presigning is not an API call. Its just crypto.

Lingo-wise, what we are doing should be a Signed URL using a Custom Policy. URL as opposed to Cookie (you can't have several of those, I need to be able to sign several) and Custom as opposed to Canned (I don't even remember the distinction anymore)

I didn't love the documentation that AWS provided but with some hacking I managed to create a signing script in bash which I could then attempt to port to Elixir. That lead to the module that follows. It uses the custom policy approach, you can read more about this stuff in the AWS docs. My use-case involves URL wildcards and signing for access to certain sections of an S3 bucket behind the CDN. I just use S3 presign for upload.

defmodule MyApp.Infrastructure.Cloudfront do
  @moduledoc """
  System tooling for signing download links and wildcard download policies.
  """
  def get_standard_expiration() do
    config = Application.get_env(:my_app, MyApp.Infrastructure.Cloudfront)
    expiration = Keyword.get(config, :expiration)
    timezone = Keyword.get(config, :timezone)

    {:ok, dt} = DateTime.now(timezone)
    dt = DateTime.add(dt, expiration, :second)
    {:ok, dt} = DateTime.shift_zone(dt, "Etc/UTC")
    dt
  end

  def get_resource_url(resource_path) do
    config = Application.get_env(:my_app, MyApp.Infrastructure.Cloudfront)
    path = Keyword.get(config, :path)
    path <> resource_path
  end

  def sign_for_resource(resource, dt_less_than \\ nil) do
    dt_less_than =
      case dt_less_than do
        nil -> get_standard_expiration()
        _ -> dt_less_than
      end

    config = Application.get_env(:my_app, MyApp.Infrastructure.Cloudfront)
    access_key_id = Keyword.get(config, :access_key_id)
    private_key = Keyword.get(config, :private_key)

    sign_for_resource(resource, dt_less_than, access_key_id, private_key)
  end

  def sign_for_resource(resource, dt_less_than, key_id, private_key) do
    unixtime = DateTime.to_unix(dt_less_than)

    payload =
      resource
      |> create_custom_policy(unixtime)

    signature =
      payload
      |> :public_key.sign(:sha, private_key)
      |> :base64.encode()

    encoded = :base64.encode(payload)

    [
      {"Policy", encoded},
      {"Signature", signature},
      {"Key-Pair-Id", key_id}
    ]
  end

  def signature_to_query_string(signature) do
    Enum.reduce(signature, nil, fn {key, value}, qs ->
      case qs do
        nil -> "#{key}=#{value}"
        _ -> qs <> "&#{key}=#{value}"
      end
    end)
  end

  def load_key(private_key_filepath) do
    {:ok, key_binary} = :file.read_file(private_key_filepath)
    [rsa_key_entry] = :public_key.pem_decode(key_binary)
    :public_key.pem_entry_decode(rsa_key_entry)
  end

  def create_custom_policy(resource, date_less_than) do
    policy = %{
      "Statement" => [
        %{
          "Resource" => resource,
          "Condition" => %{
            "DateLessThan" => %{
              "AWS:EpochTime" => date_less_than
            }
          }
        }
      ]
    }

    Jason.encode!(policy)
  end
end

As Cloudfront signing requires a private key, of course you will need to be very careful about managing your secrets, especially that particular secret. If you have good ideas on how to approach this for development I'm all ears. Currently, sharing some credentials around a very small (two people) team is a minimal issue. But I'm not happy with it as a system.

This module only has one dependency and that's Jason. You can use any JSON library or actually have the JSON policy statement as a text template. I found this convenient as we already use Jason elsewhere. Other than that I've relied on the Erlang :public_key module for crypto.

It also demands that some values be set in your config, pretty standard fare.

I think the module is fairly straight-forward to follow. You give it a resource (an URL in my case) and an expiration time (dt_less_than), it creates a custom policy for you. If you have other needs than I did you may want to change things a bit but this should give you a good place to start. The generated policy can be added on to URLs to attempt to fetch the resource with the appropriately signed policy.

So if I want my authenticated users to be able to get anything under fastfiles.underjord.io/user-files/ which I set up to be a Cloudfront distribution pointing to a non-public S3-bucket I could sign for them at login for fastfiles.underjord.io/user-files/* and they could use that to access https://fastfiles.underjord.io/user-files/image.jpg by appending the signature stuff as a query string. So you can give a time-based authorization to do something with your files to a user while still enjoying the performance of a CDN.

This is not something revolutionary. It's just an implementation. I simply didn't find any particularly good examples of doing this in code. Either Elixir or otherwise. AWS docs were rough but was what I had to go with. Punching through the SEO fog around anything Cloudfront + S3 for highly specific needs is increasingly difficult.

If you spot any problems, have any follow-up questions or anything like that, feel free to let me know at lars@underjord.io.