defmodule Image.Plug.Signing do @moduledoc """ HMAC signing and verification for `Image.Plug` request URLs. When configured, every request URL must carry a `?sig=` 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=` 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=` (and optionally `?exp=&sig=`) 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=` 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) do check_signature(path_without_sig, provided_signature, keys) 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