Skip to main content

lib/image/plug/signing.ex

defmodule Image.Plug.Signing do
  @moduledoc """
  HMAC signing and verification for `Image.Plug` request URLs.

  When configured, every request URL must carry a `?sig=<hex>` query
  parameter whose value is `HMAC-SHA256(secret, path)`, where `path`
  is the request path including any query string with the `sig`
  parameter removed. Optionally a `?exp=<unix-seconds>` parameter
  carries an expiry that the verifier checks against system time.

  ### Why query-parameter signing?

  A query parameter survives URL-rewriting intermediaries that may
  strip or reorder path segments, and it composes cleanly with the
  Cloudflare Images URL grammar (which uses `,` and `/` as
  significant path separators). Signed URLs work as drop-in
  replacements for unsigned ones — the canonical pipeline is
  unchanged.

  ### Why HMAC-SHA256?

  HMAC-SHA256 is the same primitive Cloudflare's signed-URL feature
  uses for its R2 and other URL-signing endpoints. Standard, fast,
  no key-distribution surprises. The verifier supports a list of
  keys for rotation: signing always uses the first key, verification
  accepts any.

  ### Example

      iex> keys = ["secret-1"]
      iex> path = "/cdn-cgi/image/width=200/photo.jpg"
      iex> signed = Image.Plug.Signing.sign(path, keys)
      iex> Image.Plug.Signing.verify(signed, keys, required?: true)
      :ok

  """

  alias Image.Plug.Error

  @type key :: String.t()
  @type keys :: [key(), ...]

  @signature_param "sig"
  @expiry_param "exp"

  @doc """
  Signs `path` with the first key in `keys`, returning the path with
  `?sig=<hex>` (and optionally `?exp=<unix-seconds>&sig=<hex>`)
  appended.

  ### Arguments

  * `path` is the request path string (with or without an existing
    query string). If a query string is present, the signature
    covers the full path including that query string.

  * `keys` is a non-empty list of secret-key strings. The first key
    is used for signing; the rest are accepted at verification time
    to support rotation.

  ### Options

  * `:expires_at` — `DateTime` or unix-seconds integer. When set,
    appends `?exp=<unix-seconds>` to the path and signs the result.
    Verification rejects the URL after this time.

  ### Returns

  * The path with the appropriate query parameters appended.

  ### Examples

      iex> Image.Plug.Signing.sign("/foo.jpg", ["secret"]) |> String.starts_with?("/foo.jpg?sig=")
      true

  """
  @spec sign(String.t(), keys(), keyword()) :: String.t()
  def sign(path, [primary_key | _] = _keys, options \\ [])
      when is_binary(path) and is_binary(primary_key) do
    expiry_param = encode_expiry(Keyword.get(options, :expires_at))

    base_with_expiry =
      case expiry_param do
        nil -> path
        param -> append_query(path, param)
      end

    signature = hmac(primary_key, base_with_expiry)
    append_query(base_with_expiry, "#{@signature_param}=#{signature}")
  end

  @doc """
  Verifies the signature on `path_with_query`.

  ### Arguments

  * `path_with_query` is the request path as it appeared on the
    wire, including any query string.

  * `keys` is a non-empty list of secret-key strings. Verification
    accepts a signature produced with any key in the list (for
    rotation).

  ### Options

  * `:required?` — when `true`, a missing `?sig` parameter causes a
    `:signature_required` error. When `false` (default), an
    unsigned URL passes verification — useful for gradual roll-out
    where some clients haven't been updated yet.

  * `:now` — current unix-seconds timestamp for expiry comparison.
    Defaults to `System.system_time(:second)`. Test-only override.

  ### Returns

  * `:ok` when the signature is valid (or absent and not required).

  * `{:error, %Image.Plug.Error{tag: :signature_required}}` when no
    `?sig` is present and signing is required.

  * `{:error, %Image.Plug.Error{tag: :invalid_signature}}` when the
    `?sig` value does not match any key.

  * `{:error, %Image.Plug.Error{tag: :signature_expired}}` when an
    `?exp` parameter is present and has passed.

  """
  @spec verify(String.t(), keys(), keyword()) :: :ok | {:error, Error.t()}
  def verify(path_with_query, keys, options \\ [])
      when is_binary(path_with_query) and is_list(keys) do
    required? = Keyword.get(options, :required?, false)
    now = Keyword.get(options, :now, System.system_time(:second))

    case extract_signature(path_with_query) do
      {nil, _path_without_sig} when required? ->
        {:error, Error.new(:signature_required, "request must carry a ?sig query parameter")}

      {nil, _path_without_sig} ->
        :ok

      {provided_signature, path_without_sig} ->
        with :ok <- check_expiry(path_without_sig, now),
             :ok <- check_signature(path_without_sig, provided_signature, keys) do
          :ok
        end
    end
  end

  defp encode_expiry(nil), do: nil
  defp encode_expiry(value) when is_integer(value), do: "#{@expiry_param}=#{value}"

  defp encode_expiry(%DateTime{} = dt) do
    "#{@expiry_param}=#{DateTime.to_unix(dt)}"
  end

  defp append_query(path, param) do
    case String.contains?(path, "?") do
      true -> "#{path}&#{param}"
      false -> "#{path}?#{param}"
    end
  end

  # Returns `{signature_or_nil, path_with_sig_removed}`.
  defp extract_signature(path_with_query) do
    case String.split(path_with_query, "?", parts: 2) do
      [path] ->
        {nil, path}

      [path, query] ->
        {sig, kept_params} =
          query
          |> String.split("&")
          |> Enum.reduce({nil, []}, fn entry, {sig_acc, kept_acc} ->
            case String.split(entry, "=", parts: 2) do
              [@signature_param, value] -> {value, kept_acc}
              _ -> {sig_acc, kept_acc ++ [entry]}
            end
          end)

        {sig, rebuild(path, kept_params)}
    end
  end

  defp rebuild(path, []), do: path
  defp rebuild(path, params), do: "#{path}?#{Enum.join(params, "&")}"

  defp check_expiry(path_without_sig, now) do
    case extract_expiry(path_without_sig) do
      nil ->
        :ok

      expiry when is_integer(expiry) and expiry >= now ->
        :ok

      expiry when is_integer(expiry) ->
        {:error,
         Error.new(:signature_expired, "signed URL has expired",
           details: %{expired_at: expiry, now: now}
         )}

      _ ->
        {:error, Error.new(:invalid_signature, "could not parse `?exp` parameter")}
    end
  end

  defp extract_expiry(path_with_query) do
    case String.split(path_with_query, "?", parts: 2) do
      [_path] ->
        nil

      [_path, query] ->
        query
        |> String.split("&")
        |> Enum.find_value(fn entry ->
          case String.split(entry, "=", parts: 2) do
            [@expiry_param, value] -> parse_integer(value)
            _ -> nil
          end
        end)
    end
  end

  defp parse_integer(value) do
    case Integer.parse(value) do
      {integer, ""} -> integer
      _ -> nil
    end
  end

  defp check_signature(path_without_sig, provided, keys) do
    if Enum.any?(keys, fn key ->
         Plug.Crypto.secure_compare(provided, hmac(key, path_without_sig))
       end) do
      :ok
    else
      {:error, Error.new(:invalid_signature, "request signature does not match")}
    end
  end

  defp hmac(key, payload) when is_binary(key) and is_binary(payload) do
    :crypto.mac(:hmac, :sha256, key, payload) |> Base.encode16(case: :lower)
  end
end