defmodule Image.Plug.Provider.Cloudinary.Signing do @moduledoc """ Cloudinary-flavoured URL signing. Per [Cloudinary's docs](https://cloudinary.com/documentation/control_access_to_media#signed_delivery_urls): * SHA-256 (modern, default) over the canonical-string `/`. * Signature appears as a path segment `s----` inserted between the delivery type (`upload`) and the first transform stage. * Cloudinary truncates the SHA-256 digest to 32 url-safe-base64 characters by default. We follow that convention; longer signatures are rejected as invalid even if the prefix matches. v0.1 ships SHA-256 only. Cloudinary's older SHA-1 scheme (8-char signature, no `--` delimiters around the segment) is not supported — open an issue if you need it. Sign and verify share the same wire format. The module mirrors `Image.Plug.Signing`'s public API so it's a drop-in alternative when configuring the Cloudinary provider. """ alias Image.Plug.Error @signature_re ~r/^s--([A-Za-z0-9_-]+)--$/ @digest_length 32 @doc """ Signs a Cloudinary path with the first key in `keys`. ### Arguments * `path` is the path-and-query of a Cloudinary URL **without** an `s----` segment. The signer inserts the segment between `` (e.g. `upload`) and the first transform stage. * `keys` is a non-empty list of API secrets. ### Options * `:expires_at` — currently unused. Cloudinary's signed-URL flow relies on path-bound signatures rather than a per-URL expiry parameter; expirations live on the API-secret rotation cycle. ### Returns The signed path. """ @spec sign(String.t(), [String.t(), ...], keyword()) :: String.t() def sign(path, [primary_key | _] = _keys, _options \\ []) when is_binary(path) and is_binary(primary_key) do {prefix, transforms_and_source} = split_at_delivery(path) signature = compute_signature(transforms_and_source, primary_key) "#{prefix}/s--#{signature}--/#{transforms_and_source}" end @doc """ Verifies the signature on a Cloudinary path. Returns `:ok` or an `Image.Plug.Error` with one of `:signature_required`, `:invalid_signature`. ### Options * `:required?` — when `true`, missing `s----` segment produces `:signature_required`. Default `false`. """ @spec verify(String.t(), [String.t(), ...], keyword()) :: :ok | {:error, Error.t()} def verify(path, keys, options \\ []) when is_binary(path) and is_list(keys) do required? = Keyword.get(options, :required?, false) case extract_signature(path) do {nil, _} when required? -> {:error, Error.new( :signature_required, "request must carry a Cloudinary `s----` segment" )} {nil, _} -> :ok {provided, transforms_and_source} -> check_signature(transforms_and_source, provided, keys) end end @doc """ Splits a Cloudinary path into `{prefix, transforms_and_source}` where `prefix` is `///` and the remainder is the rest of the path with no leading `/`. Used by both signing and verification to canonicalise. """ @spec split_at_delivery(String.t()) :: {String.t(), String.t()} def split_at_delivery(path) when is_binary(path) do segments = String.split(String.trim_leading(path, "/"), "/") case segments do [account, resource_type, delivery | rest] -> prefix = "/" <> account <> "/" <> resource_type <> "/" <> delivery {prefix, Enum.join(rest, "/")} _ -> {"", String.trim_leading(path, "/")} end end defp extract_signature(path) do {prefix, after_prefix} = split_at_delivery(path) case String.split(after_prefix, "/", parts: 2) do [first, rest] -> case Regex.run(@signature_re, first) do [_, sig] -> {sig, rest} _ -> {nil, prefix <> "/" <> after_prefix} end _ -> {nil, prefix <> "/" <> after_prefix} end end defp check_signature(transforms_and_source, provided, keys) do if Enum.any?(keys, fn key -> expected = compute_signature(transforms_and_source, key) Plug.Crypto.secure_compare(provided, expected) end) do :ok else {:error, Error.new(:invalid_signature, "request signature does not match")} end end # The canonical string is `/`. The # SHA-256 digest is truncated to 32 url-safe-base64 characters # (Cloudinary's default; matches `signature_algorithm: 'sha256'`). defp compute_signature(transforms_and_source, key) do :crypto.hash(:sha256, transforms_and_source <> key) |> Base.url_encode64(padding: false) |> String.slice(0, @digest_length) end end